Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
84edacc
fix(GenesisHub, hub, sessions): #512 followup - relative folder origi…
genesiscz Oct 13, 2026
7693791
fix(GenesisHub, GenesisKit, permissions): the vault recipient check i…
genesiscz Oct 19, 2026
f860d1b
feat(GenesisKit, permissions): Option+Tab (macOS Move focus to next w…
genesiscz Oct 19, 2026
7676eec
fix(GenesisKit, permissions): Option+Tab follows the last-focused ord…
genesiscz Oct 19, 2026
a80025f
feat(GenesisKit): a changed or added image in a diff shows its pictur…
genesiscz Oct 20, 2026
89802fa
feat(GenesisKit): an SVG in a diff shows as code with a Show image sw…
genesiscz Oct 20, 2026
ae958e7
fix(GenesisKit): the markdown file tree re-roots on a folder double-c…
genesiscz Oct 20, 2026
32e50f4
fix(GenesisKit, posture): a calibration records which request placed …
genesiscz Oct 20, 2026
3ae2f9a
fix(macos, widget preview): each build unregisters the retired previe…
genesiscz Oct 20, 2026
6ba2ae9
fix(macos): vault key release compares the caller's exec generation a…
genesiscz Oct 20, 2026
b083641
fix(macos): a notification reply to a caller that already left no lon…
genesiscz Oct 21, 2026
936dc1a
fix(macos): clones reclaim with --worktrees-of keeps every --dir that…
genesiscz Oct 21, 2026
42bb333
feat(macos): tools macos permissions local-network names the program …
genesiscz Oct 21, 2026
b2f0dcb
fix(macos): the vault key release inspects again, up to three times, …
genesiscz Oct 21, 2026
5879604
fix(macos): the app declares LSUIElement, so a link or a reopen no lo…
genesiscz Oct 22, 2026
f5aa238
feat(macos): the link relay follows nehelper's prompt line and shows …
genesiscz Oct 22, 2026
c03dcaf
fix(GenesisKit, GenesisHub): #556 followup - an image overwritten whi…
genesiscz Oct 22, 2026
11c5f02
fix(GenesisKit, GenesisHub): #556 followup - diff images pin blob ids…
genesiscz Oct 22, 2026
3697b20
refactor(tools): #556 followup - the GenesisTools binary helpers live…
genesiscz Oct 22, 2026
c1ae88e
fix(GenesisKit, macos): #556 followup - an image side loads the blob …
genesiscz Oct 22, 2026
45a37d9
fix(GenesisKit): #556 followup - a last-turn review shows changed ima…
genesiscz Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion scripts/build-widget-preview.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { createHash } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { cp, mkdir, rename } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { buildDiffViewer } from "@app/macos/lib/permissions/app";
import {
buildDiffViewer,
deleteRetiredStamps,
LSREGISTER,
listRetiredStamps,
readRunningExecutableInodes,
retiredStampsToDelete,
staleRegistrations,
} from "@app/macos/lib/permissions/app";
import { env } from "@genesiscz/utils/env";
import { SafeJSON } from "@genesiscz/utils/json";
import { logger, out } from "@genesiscz/utils/logger";
import { toolDataDir } from "@genesiscz/utils/storage/root";

const bundleId = "com.genesiscz.genesistools.widget-preview";
/** Replaced previews kept for a rollback; older ones are deleted after each build. */
const RETIRED_PREVIEWS_KEPT = 2;
const appName = "GenesisTools Preview.app";
const repo = resolve(import.meta.dir, "..");
const packagePath = join(repo, "src/macos/GenesisTools");
Expand Down Expand Up @@ -232,7 +243,53 @@ async function buildPreview(): Promise<void> {
throw error;
}

await pruneRetiredPreviews(destination);
out.result({ appPath: destination, bundleId, executable: "GenesisWidgetPreview", productionAppTargeted: false });
}

/**
* Every retired preview is unregistered from Launch Services, only the newest {@link RETIRED_PREVIEWS_KEPT} stay on
* disk (plus any a running Preview still executes), and every record of this bundle id at another path than
* `destination` goes. Before this, each build left its retired copy registered and on disk forever: 72 records of
* deleted copies on 2026-10-20, all offered to `open -b`. A failure is logged, never fatal: the new preview is already
* installed.
*/
async function pruneRetiredPreviews(destination: string): Promise<void> {
const root = toolDataDir("widget-preview", "retired");
try {
const stamps = listRetiredStamps(root) ?? [];
for (const stamp of stamps) {
await command([LSREGISTER, "-u", join(root, stamp, appName)]).catch((error) =>
logger.warn({ error, stamp }, "retired preview not unregistered")
);
}
const binary = (stamp: string) => join(root, stamp, appName, "Contents", "MacOS", "GenesisWidgetPreview");
const deleted = deleteRetiredStamps(
root,
retiredStampsToDelete({
stamps,
keep: RETIRED_PREVIEWS_KEPT,
running: readRunningExecutableInodes(),
inodeOf: (stamp) => (existsSync(binary(stamp)) ? statSync(binary(stamp)).ino : null),
})
);
const stale = staleRegistrations(await command([LSREGISTER, "-dump"]), bundleId, destination);
for (const path of stale) {
await command([LSREGISTER, "-u", path]).catch((error) =>
logger.warn({ error, path }, "stale preview record not unregistered")
);
}
logger.info(
{
kept: stamps.length - deleted.length,
deleted: deleted.length,
unregistered: stale.length,
},
"pruned retired widget previews"
);
} catch (error) {
logger.warn({ error, root }, "pruning retired widget previews failed; they stay");
}
}

await buildPreview();
51 changes: 50 additions & 1 deletion src/hub/lib/prs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "bun:test";
import { join } from "node:path";
import type { CommandRunner } from "@genesiscz/utils/git/origins";
import { type CommandRunner, isLocalRemoteUrl } from "@genesiscz/utils/git/origins";
import { TestRepo } from "@genesiscz/utils/git/test-repo";
import { SafeJSON } from "@genesiscz/utils/json";
import { setupStorageSandbox } from "@genesiscz/utils/storage/test-sandbox";
Expand Down Expand Up @@ -196,6 +196,55 @@ describe("hubPrs / hubPr", () => {
await expect(hubPr({ ref: "https://github.com/o/r/pull/7", runner: failing })).rejects.toThrow(PrRefError);
});
});
describe("isLocalRemoteUrl", () => {
const local = [
"/Users/dev/repo.git",
"~/repo.git",
"~user/repo.git",
"../repo.git",
"./repo.git",
"repos/source.git",
"source.git",
"file:///Users/dev/repo.git",
"file://localhost/Users/dev/repo.git",
"file:/Users/dev/repo.git",
"C:\\repo.git",
"C:/repo.git",
"C:repo.git",
"\\\\server\\share\\repo.git",
"//server/share/repo.git",
"\\repo.git",
"repos\\source.git",
".\\repo.git",
"./foo:bar",
" /abs/repo.git ",
];
const remote = [
"https://github.com/o/r.git",
"http://gitlab.example/g/r.git",
"ssh://git@github.com/o/r.git",
"ssh://git@github.com:2222/o/r.git",
"git://github.com/o/r.git",
"ftp://example.com/repo.git",
"git@github.com:o/r.git",
"github.com:o/r.git",
"git@gitlab.internal.example:group/repo.git",
"foo:bar",
"",
];

it("treats folder paths as local, including relative paths and Windows drive or UNC paths", () => {
expect(local.map((url) => ({ url, local: isLocalRemoteUrl(url) }))).toEqual(
local.map((url) => ({ url, local: true }))
);
});

it("leaves URL schemes and scp-like remotes to a forge driver", () => {
expect(remote.map((url) => ({ url, local: isLocalRemoteUrl(url) }))).toEqual(
remote.map((url) => ({ url, local: false }))
);
});
});

describe("missingCommits", () => {
it("names the commits the repository lacks and none it holds", async () => {
Expand Down
11 changes: 8 additions & 3 deletions src/macos/GenesisKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6396,6 +6396,8 @@ One reading of the user's pose. A `Codable`, `Equatable`, `Sendable` struct with
- `headphonePitch: Double? = nil`: AirPods pitch in radians, positive nodding down.
- `imageAspect: Double = 4/3`: what the overlay needs to match an aspect-fill preview.
- `lowLight: Bool = false`: too dark to judge.
- `shoulderSource: PoseShoulderSource? = nil`: the request behind the shoulders and the neck, `.body2D` or `.body3D`. Nil without shoulders.
- `shoulderAge: TimeInterval? = nil`: seconds since that request ran, 0 on the frame it ran (`hasFreshShoulders`). A throttled 3D reading is reused for several seconds.

Members: `joint(_:) -> PoseJointPoint?` returns a joint above `minimumConfidence` (0.3, Dorso's and Apple's gate); `hasPerson` is true for a confident nose, a face, both shoulders or AirPods motion. The shoulders count so that a head bent out of the frame reads as a slouch, not as away.

Expand All @@ -6408,7 +6410,7 @@ Members: `joint(_:) -> PoseJointPoint?` returns a joint above `minimumConfidence
Vision 2D body pose (`VNDetectHumanBodyPoseRequest`, `preferBackgroundProcessing`). It keeps the nearest person: the widest confident shoulders, else the most confident nose.

- `faceDetection: FaceDetection = .fallback`: `.fallback` runs face rectangles on the same frame when the body has no confident nose or misses a shoulder (a seated upper body sometimes yields no body, which is why Dorso has the same fallback); `.always` runs both on every frame (use it while calibrating, so the baseline also holds the face measures); `.never`. Change it only from the queue that calls `analyze`.
- `body3D: Body3D = .throttled`, `body3DInterval: TimeInterval = 4`: the shoulders and the neck come from `VNDetectHumanBodyPose3DRequest` instead of the 2D request, which often finds no body for a person close to a laptop camera (real frames, 2026-10-14). The two place the shoulders differently (3D about a third narrower), so a reading never mixes them. 3D costs about 80 ms of CPU a frame against 7 ms for 2D plus face: `.throttled` runs it at most once per interval and keeps its shoulders for two intervals; `.always` runs it on every frame (calibration); `.never` keeps the 2D shoulders.
- `body3D: Body3D = .throttled`, `body3DInterval: TimeInterval = 4`: the shoulders and the neck come from `VNDetectHumanBodyPose3DRequest` instead of the 2D request, which often finds no body for a person close to a laptop camera (real frames, 2026-10-14). The two place the shoulders differently (3D about a third narrower), so a reading never mixes them. 3D costs about 80 ms of CPU a frame against 7 ms for 2D plus face: `.throttled` runs it at most once per interval and keeps its shoulders for two intervals; `.always` runs it on every frame (calibration); `.never` keeps the 2D shoulders. Each sample says which request placed its shoulders and how old they are (`shoulderSource`, `shoulderAge`). `readShouldersOnNextFrame()` makes the next `analyze` run the 3D request even when it is not due.

#### `VisionFaceEngine`

Expand Down Expand Up @@ -6486,7 +6488,8 @@ A fault reads its first available measure in that order, so body joints win over

The calibrated upright posture: the upright `metrics` (median), the `tolerance` each measure may always drift (noise, looking around the screen: the spread of the upright readings, never below the measure's minimum), and the `span`, the drift that counts as one whole slouch (from the slouch sample when there was one). `Codable`.

- `static calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = []) -> PostureBaseline?`: nil with fewer than `minimumUprightReadings` (4, Dorso) usable readings.
- `static calibrate(upright: [PostureMetrics], slouch: [PostureMetrics] = [], shoulderSource: PoseShoulderSource? = nil) -> PostureBaseline?`: nil with fewer than `minimumUprightReadings` (4, Dorso) usable readings. `PostureCalibrationSession.baseline()` passes the source most of its upright samples had.
- `shoulderSource: PoseShoulderSource?`: the request that placed the shoulders while calibrating; nil in a file saved before 2026-10-20. `shoulderSourceDiffers(from:)` is true when the baseline has shoulder measures (`PostureMeasure.usesShoulders`) and its source is not the live one or is unknown: the 2D and 3D requests place the shoulders differently, so such a baseline cannot judge live shoulders.

Defaults without a slouch sample: span 0.15 and minimum tolerance 0.05 for `faceWidth` and `eyeSpanRatio` (Dorso's forward-head numbers), span 0.3 rad and tolerance 0.15 rad for pitch (Dorso's AirPods numbers); the other measures use proposals in the same units (head height span 0.2 shoulder widths, shoulder tilt 0.17 rad).

Expand All @@ -6500,7 +6503,9 @@ Scores a reading against a baseline. Pure.

Per fault: bad above `tolerance + deadZone × span`, clear again below `0.85 × tolerance + 0.7 × deadZone × span` (Dorso exits at 0.7 of its threshold), severity `(drift − enter) / (span × (1 − deadZone))` clamped to 0...1. The reading takes the worst fault (Dorso takes the max). Shoulders in view with no nose and no face, against a baseline with a head height, score as a full head drop (severity 1): the head went below the frame.

`score(_:against:) -> PostureScore` gives `signals` (one `PostureSignal` per fault, with drift, thresholds and severity), `severity`, `strength`, `isBad`, `isClear` and `worst`, the fault to name in the UI ("Head drop"), so the score is never opaque.
`score(_:against:shoulderSource:) -> PostureScore` gives `signals` (one `PostureSignal` per fault, with drift, thresholds and severity), `severity`, `strength`, `isBad`, `isClear` and `worst`, the fault to name in the UI ("Head drop"), so the score is never opaque. When the baseline's shoulder source differs from `shoulderSource` (the live one; nil skips the check), every measure that needs the shoulders is left out, the faults only they measure go to `unjudged`, and `shoulderSourceMismatch` is true: the UI asks for a new calibration.

The monitor lets a shoulder fault from the throttled 3D request turn bad only when two fresh 3D readings in a row are bad, each scored without the smoother. Until the second one agrees, the signal is `held` (neither bad nor clear, severity 0, listed in `PostureScore.held`) and the engine reads the shoulders again on the next frame. A fresh reading that is no longer bad lets the fault go at once. Without this, one wrong 3D reading held a tilt for 6.8 s (debug recording 2026-10-19).

`PostureSmoother(window: 1, maxCount: 5)` takes the median of the readings of the last second; Dorso averages 5 readings, and a time window keeps the lag the same at 2 and 10 fps.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public struct DiffFile: Codable, Identifiable, Hashable {
public var newContents: String?
/// Binary or too large to show; renderers show a placeholder instead of lines.
public var skipped: String?
/// A skipped file that is an image, or any SVG: where its two sides' bytes are, so a renderer can show the
/// picture (an SVG shows as code until the reader asks for the picture).
public var image: DiffImage?

public var name: String {
(path as NSString).lastPathComponent
Expand All @@ -28,7 +31,7 @@ public struct DiffFile: Codable, Identifiable, Hashable {
return dir.isEmpty ? "" : dir
}

public init(id: String, path: String, oldPath: String? = nil, status: Status, additions: Int, deletions: Int, oldContents: String? = nil, newContents: String? = nil, skipped: String? = nil) {
public init(id: String, path: String, oldPath: String? = nil, status: Status, additions: Int, deletions: Int, oldContents: String? = nil, newContents: String? = nil, skipped: String? = nil, image: DiffImage? = nil) {
self.id = id
self.path = path
self.oldPath = oldPath
Expand All @@ -38,6 +41,51 @@ public struct DiffFile: Codable, Identifiable, Hashable {
self.oldContents = oldContents
self.newContents = newContents
self.skipped = skipped
self.image = image
}
}

/// The two sides of an image in a diff. Nil for the side a file does not have (added: no old, deleted: no new).
public struct DiffImage: Codable, Hashable {
public enum Source: Codable, Hashable {
/// A file on disk (the working tree).
case file(String)
/// `git -C <repo> cat-file blob <object>`: the object id the diff read, or `<rev>:<path>` (an empty rev is the
/// index) when it had none. `path` names the file, whose extension is the picture's type. `gitDirectory` reads
/// the object from another store (the agents change log's) instead of the repository's.
case blob(repo: String, object: String, path: String, gitDirectory: String? = nil)
}

public var old: Source?
public var new: Source?
/// What both sides were when the diff was read: a blob side's object id, a working-tree side's modification time
/// and size. A file source names a place, not its bytes, so an image overwritten while its diff stays open is a new
/// `DiffImage` only through this.
public var revision: String?

public init(old: Source?, new: Source?, revision: String? = nil) {
self.old = old
self.new = new
self.revision = revision
}

/// `revision` for a file on disk: its modification time and size, nil when it cannot be read.
public static func revision(ofFile path: String) -> String? {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: path),
let modified = attributes[.modificationDate] as? Date,
let size = attributes[.size] as? NSNumber else { return nil }
return "\(modified.timeIntervalSince1970)-\(size)"
}

/// The binary formats WebKit draws. SVG is text: it has code to show, so it is asked for by `isSVG`.
public static let extensions: Set<String> = ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "avif", "bmp", "ico", "tif", "tiff"]

public static func isImage(_ path: String) -> Bool {
extensions.contains((path as NSString).pathExtension.lowercased())
}

public static func isSVG(_ path: String) -> Bool {
(path as NSString).pathExtension.lowercased() == "svg"
}
}

Expand Down
Loading
Loading