gentle-pi 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -3,8 +3,18 @@
3
3
  * owned Node child (`node --experimental-strip-types --max-old-space-size=32`)
4
4
  * and speaks a single bounded JSON envelope on stdout:
5
5
  * probe -> { schema, ok, available, formats }
6
- * play -> { schema, ok, played }
7
- * Failures are private: a generic `{ schema, ok:false }` and a nonzero exit.
6
+ * Serve protocol (persistent mode): line-delimited JSON requests on stdin,
7
+ * `{ schema, op: "play", path }`, answered by one bounded
8
+ * `{ schema, ok, played }` envelope per play on stdout. Requests carry a
9
+ * snapshot PATH reference, not inline bytes: the worker re-reads the owner's
10
+ * private snapshot with the same bounded nofollow `readSnapshot()` on every
11
+ * play, so the fail-closed validation of snapshots is preserved verbatim and
12
+ * no audio bytes ever cross the stdio channel (request lines stay tiny and
13
+ * bounded). Protocol violations crash the worker with a nonzero exit and the
14
+ * owner respawns it on the next play; stdin EOF (owner death) exits cleanly
15
+ * and an idle worker self-retires, so teardown needs no owner round trip.
16
+ * Failures are private: a generic `{ schema, ok:false }` and, for protocol
17
+ * violations, a nonzero exit.
8
18
  * It reads only argv, the environment and the owner-supplied snapshot; no SDK,
9
19
  * UI, extension, config-owner, cookie-content logging or OS audio surface.
10
20
  */
@@ -15,18 +25,25 @@ import { playPulseWav } from "./notification-pulse-stream.ts";
15
25
 
16
26
  const SCHEMA = "gentle.audio.pulse/v1";
17
27
  const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024;
28
+ /** Request lines carry only a schema/op/path triple; anything longer is a protocol violation. */
29
+ const MAX_REQUEST_BYTES = 4096;
30
+ /** Bounded request backlog; an overflow is a protocol violation (crash, owner restarts). */
31
+ const MAX_PENDING_REQUESTS = 16;
32
+ /** An idle worker self-retires so one owner process never accumulates workers; unref'd, never busy. */
33
+ const SERVE_IDLE_EXIT_MS = 60000;
18
34
 
19
35
  function emit(payload: Record<string, unknown>): void {
20
36
  process.stdout.write(`${JSON.stringify({ schema: SCHEMA, ...payload })}\n`);
21
37
  }
22
38
 
23
- /** Re-read the trusted owner snapshot with the same bounded nofollow pattern. */
24
- async function readSnapshot(path: string): Promise<Buffer> {
39
+ /** Re-read the trusted owner snapshot with the same bounded nofollow pattern.
40
+ * `buffer` is a caller-owned scratch space so persistent serving never
41
+ * reallocates the 2 MiB read window per play; the returned bytes are a copy. */
42
+ async function readSnapshot(path: string, buffer: Buffer): Promise<Buffer> {
25
43
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
44
  try {
27
45
  const stat = await handle.stat();
28
46
  if (!stat.isFile() || stat.size < 44 || stat.size > MAX_SNAPSHOT_BYTES) throw new Error("invalid snapshot");
29
- const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
30
47
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
31
48
  if (bytesRead !== stat.size) throw new Error("invalid snapshot");
32
49
  return Buffer.from(buffer.subarray(0, bytesRead));
@@ -35,6 +52,63 @@ async function readSnapshot(path: string): Promise<Buffer> {
35
52
  }
36
53
  }
37
54
 
55
+ /** Protocol violation: one generic envelope so the waiting owner settles, then a crash. */
56
+ function crash(): never {
57
+ emit({ ok: false });
58
+ process.exit(1);
59
+ }
60
+
61
+ async function serve(): Promise<void> {
62
+ const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
63
+ const queue: string[] = [];
64
+ let running = false;
65
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
66
+ const armIdle = () => {
67
+ clearTimeout(idleTimer);
68
+ idleTimer = setTimeout(() => { if (queue.length === 0 && !running) process.exit(0); }, SERVE_IDLE_EXIT_MS);
69
+ idleTimer.unref?.();
70
+ };
71
+ const runOne = async (line: string): Promise<void> => {
72
+ let request: { schema?: unknown; op?: unknown; path?: unknown };
73
+ try {
74
+ const parsed: unknown = JSON.parse(line);
75
+ if (typeof parsed !== "object" || parsed === null) return crash();
76
+ request = parsed as { schema?: unknown; op?: unknown; path?: unknown };
77
+ } catch { return crash(); }
78
+ if (request.schema !== SCHEMA || request.op !== "play" || typeof request.path !== "string" || request.path.length === 0) return crash();
79
+ try {
80
+ const bytes = await readSnapshot(request.path, buffer);
81
+ await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
82
+ emit({ ok: true, played: true });
83
+ } catch { emit({ ok: false }); } // A private play failure leaves the healthy worker serviceable.
84
+ };
85
+ const drain = async (): Promise<void> => {
86
+ if (running) return;
87
+ running = true;
88
+ while (queue.length > 0) { const line = queue.shift()!; armIdle(); await runOne(line); }
89
+ running = false;
90
+ armIdle();
91
+ };
92
+ process.stdin.setEncoding("utf8");
93
+ let text = "";
94
+ process.stdin.on("data", (chunk: string) => {
95
+ armIdle();
96
+ text += chunk;
97
+ for (;;) {
98
+ const index = text.indexOf("\n");
99
+ if (index === -1) break;
100
+ const line = text.slice(0, index);
101
+ text = text.slice(index + 1);
102
+ if (line.length > MAX_REQUEST_BYTES || queue.push(line) > MAX_PENDING_REQUESTS) crash();
103
+ }
104
+ if (text.length > MAX_REQUEST_BYTES) crash(); // Unterminated request line over the bound.
105
+ void drain();
106
+ });
107
+ process.stdin.on("end", () => process.exit(0));
108
+ process.stdin.on("error", () => process.exit(1));
109
+ armIdle();
110
+ }
111
+
38
112
  async function main(): Promise<void> {
39
113
  const mode = process.argv[2];
40
114
  if (mode === "probe") {
@@ -46,21 +120,11 @@ async function main(): Promise<void> {
46
120
  }
47
121
  return;
48
122
  }
49
- if (mode === "play") {
50
- const snapshot = process.argv[3];
51
- if (snapshot === undefined) { emit({ ok: false }); process.exitCode = 1; return; }
52
- try {
53
- const bytes = await readSnapshot(snapshot);
54
- await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
55
- emit({ ok: true, played: true });
56
- } catch {
57
- emit({ ok: false });
58
- process.exitCode = 1;
59
- }
123
+ if (mode === "serve") {
124
+ await serve();
60
125
  return;
61
126
  }
62
- emit({ ok: false });
63
- process.exitCode = 1;
127
+ crash();
64
128
  }
65
129
 
66
130
  await main();
@@ -15,7 +15,7 @@ export interface NotificationServiceDependencies {
15
15
  policy?: NotificationOptions;
16
16
  read?: () => NotificationResolution;
17
17
  write?: (settings: NotificationSettings) => string;
18
- backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities">;
18
+ backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"> & { dispose?: () => void };
19
19
  setTimeout?: SchedulerOptions["setTimeout"];
20
20
  clearTimeout?: SchedulerOptions["clearTimeout"];
21
21
  }
@@ -175,6 +175,10 @@ export function claimNotificationOwner(retireRuntime: () => void, deps: Notifica
175
175
  const retire = () => {
176
176
  if (disposed) return;
177
177
  shutdown(); disposed = true; retireRuntime();
178
+ // End of life for the claim also ends the audio backend: the persistent
179
+ // serve worker (if one was spawned) is killed here; a re-bind through
180
+ // attach() keeps it, and an abandoned claim self-retires idle after 60 s.
181
+ backend.dispose?.();
178
182
  if (shared.ownerLease === lease) { shared.ownerLease = undefined; shared.retire = undefined; }
179
183
  };
180
184
  shared.retire = retire;
@@ -171,18 +171,25 @@ function reason(error: unknown) {
171
171
  return code === "ENOENT" ? "missing" : code ? "io-error" : error instanceof SyntaxError ? "malformed" : (error as Error).message;
172
172
  }
173
173
  function atomicWrite(root: string, name: string, bytes: string) {
174
- directory(root, true);
174
+ // Ancestor validation is the heartbeat's cached responsibility; the destination
175
+ // identity check remains per-write so a replaced or linked file is never overwritten.
175
176
  const destination = join(root, name);
176
177
  try { regular(fs.lstatSync(destination)); }
177
178
  catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
178
179
  const temp = join(root, `.${name}.${randomUUID()}.tmp`);
179
180
  const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
181
+ let identity: { dev: number; ino: number };
182
+ try {
183
+ fs.writeFileSync(fd, bytes);
184
+ const stat = fs.fstatSync(fd);
185
+ identity = { dev: stat.dev, ino: stat.ino };
186
+ } finally { fs.closeSync(fd); }
180
187
  try {
181
- try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
182
188
  fs.renameSync(temp, destination);
183
189
  } finally {
184
190
  try { fs.unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
185
191
  }
192
+ return identity;
186
193
  }
187
194
 
188
195
  export interface PresencePage {
@@ -301,6 +308,11 @@ export class PresencePublisher {
301
308
  private heartbeat?: ReturnType<typeof setInterval>;
302
309
  private disposed = false;
303
310
  private owned = new Map<string, { dev: number; ino: number }>();
311
+ /** Root paths whose ancestor chain was validated for this session identity.
312
+ * Seeded by the one synchronous rootFor() in start(); refreshed asynchronously
313
+ * by the heartbeat so writes never probe directories on the caller's thread. */
314
+ private readonly validRoots = new Set<string>();
315
+ private validating = false;
304
316
  /** Timer I/O failures stop publication; consumers still apply the recent TTL. */
305
317
  error?: string;
306
318
 
@@ -313,10 +325,10 @@ export class PresencePublisher {
313
325
  static start(options: { profile: string; sessionId: string; label: string; labelSource?: () => string; activity: readonly ActivityInput[] }) {
314
326
  const publisher = new PresencePublisher(options);
315
327
  try {
316
- rootFor(options.profile, true);
328
+ publisher.validRoots.add(rootFor(options.profile, true));
317
329
  publisher.pending = JSON.stringify(projectActivity(options.activity));
318
330
  publisher.flush();
319
- publisher.heartbeat = setInterval(() => publisher.guarded(() => publisher.publishHeader()), 5000);
331
+ publisher.heartbeat = setInterval(() => publisher.pulse(), 5000);
320
332
  publisher.heartbeat.unref();
321
333
  return publisher;
322
334
  } catch (error) { publisher.dispose(); throw error; }
@@ -384,12 +396,68 @@ export class PresencePublisher {
384
396
  if (this.disposed) return;
385
397
  try { action(); } catch (error) { this.error = reason(error); this.dispose(); }
386
398
  }
399
+ private rootPath(kind: "header" | "activity" | "discovery" | "catalog") {
400
+ return join(resolve(this.profile), "gentle-agents", kind === "catalog" ? "catalog" : "presence");
401
+ }
402
+ /** Publishes from the warm cache synchronously, then refreshes the validated
403
+ * ancestor chain asynchronously without ever overlapping itself. */
404
+ private pulse() {
405
+ this.guarded(() => this.publishHeader());
406
+ if (this.validating) return;
407
+ this.validating = true;
408
+ void this.revalidate().finally(() => { this.validating = false; }).catch(() => { /* revalidate never rejects */ });
409
+ }
410
+ /** Async mirror of rootFor(): same ancestor chain, modes, and ownership, so the
411
+ * sync write path stays cache-only. A failed validation only cools the cache. */
412
+ private async revalidate() {
413
+ const absolute = resolve(this.profile);
414
+ const shared = join(absolute, "gentle-agents");
415
+ const chains: { root: string; create: boolean }[] = [{ root: join(shared, "presence"), create: false }];
416
+ if (this.catalog) chains.push({ root: join(shared, "catalog"), create: true });
417
+ for (const { root, create } of chains) {
418
+ try {
419
+ for (let path = absolute;; path = dirname(path)) {
420
+ const stat = await fs.promises.lstat(path);
421
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe-directory");
422
+ if (dirname(path) === path) break;
423
+ }
424
+ if (create) {
425
+ try { await fs.promises.mkdir(root, { mode: 0o700 }); }
426
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
427
+ }
428
+ await this.checkedDirectory(shared, false);
429
+ await this.checkedDirectory(root, true);
430
+ this.validRoots.add(root);
431
+ } catch { this.validRoots.delete(root); }
432
+ }
433
+ }
434
+ private async checkedDirectory(path: string, privateMode: boolean) {
435
+ const stat = await fs.promises.lstat(path);
436
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32"
437
+ && ((stat.mode & (privateMode ? 0o077 : 0o022)) !== 0 || stat.uid !== process.getuid?.()))) throw new Error("unsafe-directory");
438
+ }
387
439
  private write(kind: "header" | "activity" | "discovery" | "catalog", bytes: string) {
388
- const root = rootFor(this.profile, kind === "catalog", kind === "catalog");
440
+ // A cold cache validates synchronously exactly once for that path — the same
441
+ // rootFor() validation the old per-write path performed — then caches and
442
+ // writes, so the first publication after a cold start is never deferred to a
443
+ // heartbeat. A failed validation (vanished or unsafe tree) is the only skip
444
+ // path; the heartbeat's async refresh re-warms the cache once the tree returns.
445
+ const root = this.rootPath(kind);
446
+ if (!this.validRoots.has(root)) {
447
+ try { rootFor(this.profile, kind === "catalog", kind === "catalog"); }
448
+ catch { return; }
449
+ this.validRoots.add(root);
450
+ }
389
451
  const name = kind === "catalog" ? `${this.target.sessionHash}.${this.target.incarnation}.json` : filename(this.target, kind);
390
- atomicWrite(root, name, bytes);
391
- const { dev, ino } = fs.lstatSync(join(root, name));
392
- this.owned.set(join(root, name), { dev, ino });
452
+ let identity: { dev: number; ino: number };
453
+ try { identity = atomicWrite(root, name, bytes); }
454
+ catch (error) {
455
+ // A tree that vanished mid-flight is skipped, not fatal; other failures stay fatal.
456
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
457
+ this.validRoots.delete(root);
458
+ return;
459
+ }
460
+ this.owned.set(join(root, name), identity);
393
461
  }
394
462
  private writeDiscovery() {
395
463
  if (!this.discovery) return;
@@ -402,6 +470,7 @@ export class PresencePublisher {
402
470
  } catch { /* Metadata failure must not withdraw an existing activity peer. */ }
403
471
  }
404
472
  private publishHeader() {
473
+ if (!this.validRoots.has(this.rootPath("header"))) return;
405
474
  const header = { ...this.header, label: label(this.labelSource?.() ?? this.displayLabel), heartbeat: Date.now() };
406
475
  if (!validHeader(header)) throw new Error("malformed-header");
407
476
  this.write("header", JSON.stringify(header));
@@ -410,6 +479,7 @@ export class PresencePublisher {
410
479
  }
411
480
  private flush() {
412
481
  if (this.pending === this.published) return;
482
+ if (!this.validRoots.has(this.rootPath("activity"))) return;
413
483
  const activity: Activity = JSON.parse(this.pending);
414
484
  const generation = (this.header?.generation ?? 0) + 1;
415
485
  const bytes = JSON.stringify({ schema: 1, ...this.target, generation, activity });
@@ -62,6 +62,16 @@ export class OrchestratorScopeCache {
62
62
  this.facts.clear();
63
63
  this.snapshot = undefined;
64
64
  }
65
+ /** Snapshot for deferred (render-cadence trailing) publications: it never
66
+ * touches the resolver. A deferred publish reuses the last event-time
67
+ * projection as-is even when membership changed since — its Git resolution
68
+ * must never race a peer metadata query, whose contract adds no caller Git
69
+ * probes. Returns undefined when nothing has been projected yet (or the
70
+ * cache was cleared), in which case the caller omits the scope rather than
71
+ * probing; the next event-time publication refreshes the facts. */
72
+ cachedScope(): RecordedScope | undefined {
73
+ return this.snapshot;
74
+ }
65
75
  project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
66
76
  const registered = [...new Set(roots)];
67
77
  const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
@@ -89,11 +89,19 @@ export class OrchestratorStateCache {
89
89
  const previous = this.value?.aliases;
90
90
  // Null/null marks a new publication with no declaration yet; legacy absence
91
91
  // cannot establish historical first-task knowledge.
92
+ // An explicitly published work topic is owner-curated public task
93
+ // classification, so it refreshes the current alias when the caller omits
94
+ // the subject (#2553). State-only refreshes and automatic republications of
95
+ // unchanged state must not re-derive it, and an explicit subject in the
96
+ // same publication always wins.
97
+ const topic = input !== undefined && !declared && state?.work?.topic
98
+ ? normalizeTaskSubject(state.work.topic) : undefined;
92
99
  let aliases = previous;
93
- if (declared) {
100
+ if (declared || topic) {
101
+ const current = declared ?? topic;
94
102
  let initialAlias = previous?.initialAlias ?? null;
95
- if (!this.value || previous?.currentAlias === null) initialAlias = declared;
96
- aliases = { initialAlias, currentAlias: declared };
103
+ if (!this.value || previous?.currentAlias === null) initialAlias = current;
104
+ aliases = { initialAlias, currentAlias: current };
97
105
  } else if (!this.value) aliases = { initialAlias: null, currentAlias: null };
98
106
  const cwd = input === undefined && this.value ? this.value.cwd : manager.getCwd();
99
107
  const record = decodePublishedState({ schema: state?.work ? 2 : 1, sessionId: manager.getSessionId(),
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { gentlePiConfigHome } from "./agent-home.ts";
5
5
 
6
- export type PermissionMode = "normal" | "auto" | "yolo";
6
+ export type PermissionMode = "normal" | "auto" | "yolo" | "super-yolo";
7
7
  export const PERMISSION_MODE_SCHEMA = "gentle-pi.permission-mode/v1";
8
8
  export interface PermissionModeOptions { gentlePiConfigHome?: string }
9
9
  export interface PermissionModeResolution {
@@ -13,7 +13,7 @@ export interface PermissionModeResolution {
13
13
  globalFile: string;
14
14
  }
15
15
  export function isPermissionMode(value: unknown): value is PermissionMode {
16
- return value === "normal" || value === "auto" || value === "yolo";
16
+ return value === "normal" || value === "auto" || value === "yolo" || value === "super-yolo";
17
17
  }
18
18
  /** Never consult merged Pi settings or repository configuration. */
19
19
  export function permissionModePolicyFile(options: PermissionModeOptions = {}): string {
@@ -630,8 +630,15 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
630
630
  // are TypeErrors — a programmer mistake, never a typed relay refusal — and
631
631
  // are checked before the business-level reviewer selection below.
632
632
  assertTokens("capture", request.captureArgumentTokens);
633
- const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
634
- if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
633
+ // A caller-supplied executable path is input, validated like every other
634
+ // input before anything else. Only the environment-dependent resolution of
635
+ // an unspecified executable is deferred below: the executable supply is
636
+ // environment state (a missing pinned binary, an invalid dev override, a
637
+ // transient filesystem error under load), and letting its untyped errors
638
+ // escape ahead of the config check turned the typed configuration refusal
639
+ // into an environment-dependent unknown failure (gentle-shell#2256).
640
+ const suppliedExecutable = request.gentleAiExecutable;
641
+ if (suppliedExecutable !== undefined && !isAbsolute(suppliedExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
635
642
  // Caller-owned reviewer selection is validated before any process launches:
636
643
  // a broken configuration is a typed refusal, never a mid-review transport
637
644
  // failure (gentle-pi#311 P2).
@@ -640,6 +647,8 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
640
647
  // slot without a provider-owned submission is a typed contract mismatch,
641
648
  // never a synthesized invocation.
642
649
  resolveReviewHostRelaySubmission(request.submission);
650
+ const gentleAiExecutable = suppliedExecutable ?? resolveGentleAiBinary();
651
+ if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
643
652
  const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
644
653
  const submission = request.submission === undefined ? undefined : Object.freeze({
645
654
  operationToken: request.submission.operationToken,
@@ -6,6 +6,9 @@ export const REVIEW_REMINDER_RECEIPT = "gentle-pi.review-reminder-receipt/v1";
6
6
  export interface ReceiptSession {
7
7
  getSessionId(): string;
8
8
  getBranch(): readonly { type: string; customType?: string; data?: unknown }[];
9
+ /** Append-only identity for memoizing repeated scans; hosts without it stay uncached. */
10
+ getLeafId?(): string | null;
11
+ getEntryCount?(): number;
9
12
  }
10
13
  interface ReceiptHost { appendEntry(type: string, data: unknown): void }
11
14
  export interface MutationEvidence {
@@ -59,7 +62,14 @@ function receipts(session: ReceiptSession, root: string): Receipt[] {
59
62
 
60
63
  // The first receipt per mutation id, in branch order, plus the index of the
61
64
  // last consumed one (-1 when nothing was consumed).
62
- function mutationLedger(session: ReceiptSession, root: string): { mutations: Mutation[]; consumed: number } {
65
+ type Ledger = { mutations: Mutation[]; consumed: number };
66
+ // Entries are append-only, so an unchanged (leafId, entryCount) identity means
67
+ // the scan result is unchanged: each (session, root) ledger is memoized until
68
+ // the next append (which moves the leaf) invalidates it. Hosts without leaf
69
+ // identity stay uncached, the same way sessionStats must.
70
+ const ledgerMemo = new WeakMap<object, Map<string, Ledger>>();
71
+
72
+ function scanLedger(session: ReceiptSession, root: string): Ledger {
63
73
  const mutations: Mutation[] = [];
64
74
  let consumed = -1;
65
75
  for (const receipt of receipts(session, root)) {
@@ -74,6 +84,19 @@ function mutationLedger(session: ReceiptSession, root: string): { mutations: Mut
74
84
  return { mutations, consumed };
75
85
  }
76
86
 
87
+ function mutationLedger(session: ReceiptSession, root: string): Ledger {
88
+ const leafId = typeof session.getLeafId === "function" ? session.getLeafId() : null;
89
+ if (leafId === null) return scanLedger(session, root);
90
+ const key = JSON.stringify([session.getSessionId(), leafId, session.getEntryCount?.(), root] as const);
91
+ let byKey = ledgerMemo.get(session);
92
+ const cached = byKey?.get(key);
93
+ if (cached) return cached;
94
+ const ledger = scanLedger(session, root);
95
+ if (!byKey) ledgerMemo.set(session, (byKey = new Map()));
96
+ byKey.set(key, ledger);
97
+ return ledger;
98
+ }
99
+
77
100
  export function pendingReviewMutation(session: ReceiptSession, root: string, captured?: string): string | undefined {
78
101
  const { mutations, consumed } = mutationLedger(session, root);
79
102
  if (captured !== undefined) return mutations.findIndex((mutation) => mutation.id === captured) > consumed ? captured : undefined;