gentle-pi 4.3.0 → 4.4.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 (208) hide show
  1. package/README.md +8 -5
  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 +101 -151
  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 +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  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 +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  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 +2 -0
  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 +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -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/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -14,6 +14,7 @@ import {
14
14
  parseFrontmatter,
15
15
  parseModelRef,
16
16
  resolveAgentProfile,
17
+ unmatchedProfileKeys,
17
18
  withPinnedModelProfiles,
18
19
  } from "../lib/agents-config.ts";
19
20
  import { THINKING_LEVELS } from "../lib/model-routing-authority.ts";
@@ -247,3 +248,63 @@ test("a pinned profile replaces subagent routing and leaves every other default
247
248
  // repository can pin "everything inherits" without touching the global store.
248
249
  assert.deepEqual(withPinnedModelProfiles(global, {}).modelProfiles, {});
249
250
  });
251
+
252
+ // Issue #2196 rework (PR #2233 maintainer decision): role keys that name no
253
+ // discovered agent must be reported, with a did-you-mean suggestion, instead of
254
+ // being silently ignored while the child inherits the orchestrator model.
255
+ test("unmatchedProfileKeys reports profile keys that match no discovered agent", () => {
256
+ const worker = {
257
+ name: "gentle-ai-worker",
258
+ description: "",
259
+ filePath: "/x/worker.md",
260
+ scope: "global" as const,
261
+ instructions: "",
262
+ model: undefined,
263
+ thinking: undefined,
264
+ mode: undefined,
265
+ tools: [],
266
+ };
267
+ const explore = { ...worker, name: "gentle-ai-explore" };
268
+ const profiles = {
269
+ worker: { model: { provider: "nan", id: "glm5.3" }, thinking: undefined },
270
+ WORKER: { model: { provider: "nan", id: "glm5.3" }, thinking: undefined },
271
+ review_risk: { model: undefined, thinking: "high" as const },
272
+ explore: { model: undefined, thinking: undefined },
273
+ };
274
+ assert.deepEqual(unmatchedProfileKeys(profiles, [worker, explore]), [
275
+ { key: "worker", suggestion: "gentle-ai-worker" },
276
+ { key: "WORKER", suggestion: "gentle-ai-worker" },
277
+ { key: "review_risk", suggestion: undefined },
278
+ { key: "explore", suggestion: "gentle-ai-explore" },
279
+ ]);
280
+ // A key that exactly matches an agent name never reports, and an empty
281
+ // profile set reports nothing.
282
+ assert.deepEqual(unmatchedProfileKeys({ "gentle-ai-worker": { model: undefined, thinking: undefined } }, [worker]), []);
283
+ assert.deepEqual(unmatchedProfileKeys({}, [worker]), []);
284
+ });
285
+
286
+ // Cortex QA adversarial follow-up (R5 pin): the did-you-mean mapping exists to
287
+ // make a misconfiguration visible, never to resolve; a short key must keep
288
+ // yielding an unresolved profile so the role inherits frontmatter and default
289
+ // routing exactly as before the fix.
290
+ test("resolveAgentProfile never resolves through the suggestion mapping", () => {
291
+ const worker = {
292
+ name: "gentle-ai-worker",
293
+ description: "",
294
+ filePath: "/x/worker.md",
295
+ scope: "global" as const,
296
+ instructions: "",
297
+ model: { provider: "openai-codex", id: "gpt-5.6-terra" },
298
+ thinking: undefined,
299
+ mode: undefined,
300
+ tools: [],
301
+ };
302
+ const config = parseAgentsConfig(
303
+ { model_profiles: { worker: { model: "nan/glm5.3", effort: "high" } } },
304
+ undefined,
305
+ );
306
+ const resolved = resolveAgentProfile(worker, config);
307
+ assert.equal(resolved.model?.provider, "openai-codex", "the short key must not route; the agent frontmatter model wins");
308
+ assert.equal(resolved.source.model, "definition");
309
+ assert.ok(unmatchedProfileKeys(config.modelProfiles, [worker]).some((entry) => entry.key === "worker" && entry.suggestion === "gentle-ai-worker"), "the same short key is still reported with its suggestion");
310
+ });
@@ -17,7 +17,7 @@ export interface FakeChild {
17
17
  message(event: Record<string, unknown>): void;
18
18
  }
19
19
 
20
- export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}): FakeChild {
20
+ export function fakeChild(options: { exitOnKill?: boolean; exitOnStdinEnd?: boolean; pid?: number } = {}): FakeChild {
21
21
  const emitter = new EventEmitter();
22
22
  const stdin = new PassThrough();
23
23
  const stdout = new PassThrough();
@@ -38,6 +38,13 @@ export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}):
38
38
  stdout.write(`${JSON.stringify({ type: "response", id: command.id, command: command.type, success: true, data })}\n`);
39
39
  }
40
40
  });
41
+ const origEnd = stdin.end.bind(stdin);
42
+ stdin.end = ((...args: unknown[]) => {
43
+ const res = (origEnd as Function)(...args);
44
+ // Preserve existing fixtures while allowing EOF and signal exits to vary independently.
45
+ if (options.exitOnStdinEnd ?? options.exitOnKill ?? true) queueMicrotask(() => emitter.emit("exit", 0, null));
46
+ return res;
47
+ }) as typeof stdin.end;
41
48
  const child: ChildLike = {
42
49
  pid: options.pid,
43
50
  stdin,
@@ -10,7 +10,9 @@ import {
10
10
  TASK_STATUS,
11
11
  taskLabel,
12
12
  TaskStore,
13
+ TERMINAL_RETENTION,
13
14
  THREAD_ITEM,
15
+ isFinished,
14
16
  type TaskRecord,
15
17
  } from "../lib/agents-protocol.ts";
16
18
 
@@ -274,6 +276,36 @@ test("TaskStore notifies only the listeners of the task that changed and keeps s
274
276
  assert.deepEqual(store.thread("missing"), emptyThread());
275
277
  });
276
278
 
279
+ // Finished tasks must release their weight: a bounded window of the most
280
+ // recent ones stays readable, older ones leave the store entirely, and a
281
+ // terminal task's listeners go quiet after the one final transition.
282
+ test("TaskStore keeps a bounded window of finished tasks and silences terminal listeners", () => {
283
+ const store = new TaskStore();
284
+ const seen: string[] = [];
285
+ store.add(record({ id: "watched", status: TASK_STATUS.RUNNING, lastActivityAt: 1 }));
286
+ store.add(record({ id: "filler", status: TASK_STATUS.RUNNING, lastActivityAt: 2 }));
287
+ store.subscribe("watched", (task) => seen.push(task.status));
288
+ // `watched` completes the window once it finishes, so the next terminal
289
+ // task evicts exactly the oldest one.
290
+ for (let index = 0; index < TERMINAL_RETENTION - 1; index += 1) {
291
+ store.add(record({ id: `done-${index}`, status: TASK_STATUS.COMPLETED, lastActivityAt: 10 + index }));
292
+ }
293
+ assert.equal(store.list().filter((task) => isFinished(task.status)).length, TERMINAL_RETENTION - 1, "retained finished tasks stay at the cap");
294
+ assert.ok(store.get("watched") && store.get("filler"), "running tasks are never evicted");
295
+ store.update("watched", { status: TASK_STATUS.COMPLETED, endedAt: 50 });
296
+ assert.deepEqual(seen, ["completed"], "the terminal transition itself still notifies exactly once");
297
+ store.apply("watched", { type: TASK_EVENT.TEXT, text: "late" }, 60);
298
+ assert.deepEqual(seen, ["completed"], "a terminal task's listener stays quiet afterwards");
299
+ store.add(record({ id: "newest", status: TASK_STATUS.COMPLETED, lastActivityAt: 1_000 }));
300
+ assert.equal(store.get("done-0"), undefined, "the oldest finished task is evicted");
301
+ assert.ok(store.get("done-1") && store.get("newest"), "recent finished tasks remain readable");
302
+ assert.ok(store.get("filler"), "running tasks survive eviction");
303
+ assert.equal(store.list().filter((task) => isFinished(task.status)).length, TERMINAL_RETENTION);
304
+ assert.deepEqual(store.thread("done-0"), emptyThread(), "an evicted task's thread weight is released");
305
+ assert.ok(store.thread("done-1").limits.maxItems > 0, "a retained finished task stays renderable");
306
+ assert.equal(store.summary().finished, TERMINAL_RETENTION, "summaries reflect the bounded window");
307
+ });
308
+
277
309
  test("TaskStore.apply moves the task to waiting on ask, back to running on any later event, and counts turns", () => {
278
310
  const store = new TaskStore();
279
311
  store.add(record({ id: "a", status: TASK_STATUS.RUNNING }));
@@ -34,7 +34,7 @@ interface Harness {
34
34
  advance(ms: number): void;
35
35
  }
36
36
 
37
- function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
37
+ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; cleanupTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; exitOnStdinEnd?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
38
38
  const children: FakeChild[] = [];
39
39
  const timers: Harness["timers"] = [];
40
40
  const asks: Harness["asks"] = [];
@@ -48,7 +48,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
48
48
  spawn: (command, args, launchOptions) => {
49
49
  if (options.failStart) throw new Error("fixture spawn failed");
50
50
  spawnOptions.push({ command, args, env: launchOptions.env, stdio: launchOptions.stdio });
51
- const fake = fakeChild({ exitOnKill: options.exitOnKill, pid: options.pid });
51
+ const fake = fakeChild({ exitOnKill: options.exitOnKill, exitOnStdinEnd: options.exitOnStdinEnd, pid: options.pid });
52
52
  if (options.state !== undefined) {
53
53
  fake.child.stdin.removeAllListeners("data");
54
54
  fake.child.stdin.on("data", (chunk) => {
@@ -73,7 +73,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
73
73
  pi: { command: "pi", args: [] },
74
74
  };
75
75
  const store = new TaskStore();
76
- const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs }, deps, {
76
+ const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs }, deps, {
77
77
  askUser: async (taskId, ask) => {
78
78
  asks.push({ taskId, method: ask.method });
79
79
  return options.answer ?? { value: "yes" };
@@ -95,6 +95,87 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
95
95
 
96
96
  const tick = () => new Promise((resolve) => setImmediate(resolve));
97
97
 
98
+ for (const mode of [AGENT_MODE.TASK, AGENT_MODE.BACKGROUND]) {
99
+ for (const policy of [undefined, "off", "malformed"] as const) {
100
+ test(`background capability admission rejects ${mode} with ${policy ?? "default"} policy`, async (t) => {
101
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
102
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
103
+ const config = join(root, "config");
104
+ const file = join(config, "background-subagents.json");
105
+ fs.mkdirSync(config);
106
+ const raw = policy === "malformed" ? "{broken" : JSON.stringify({ schema: "gentle-pi.background-subagents/v1", policy });
107
+ if (policy) fs.writeFileSync(file, raw);
108
+ const h = harness({ maxConcurrency: 1 });
109
+ let launches = 0;
110
+ const env = { GENTLE_PI_CONFIG_HOME: config, ...(policy === "malformed" ? { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } : {}) };
111
+ const task = h.runner.run(request({ cwd: root, env, mode, agent: { ...explorer, tools: ["read", "bash_background"] }, onLaunch: () => { launches++; } }));
112
+ await tick();
113
+ assert.equal(h.store.get(task.id)?.status, TASK_STATUS.FAILED);
114
+ const target = policy ? file : join(root, ".pi", "gentle-ai", "background-subagents.json");
115
+ assert.equal(h.store.get(task.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${target} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
116
+ assert.equal(h.children.length, 0);
117
+ assert.equal(h.spawnOptions.length, 0);
118
+ assert.equal(h.timers.length, 0);
119
+ assert.equal(launches, 0);
120
+ assert.deepEqual(h.finishes, [task.id]);
121
+ assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
122
+ assert.equal(existsSync(file), policy !== undefined);
123
+ if (policy) assert.equal(readFileSync(file, "utf8"), raw);
124
+ const next = h.runner.run(request({ cwd: root, env }));
125
+ await tick();
126
+ assert.equal(h.store.get(next.id)?.status, TASK_STATUS.RUNNING);
127
+ h.runner.cancel(next.id);
128
+ await tick();
129
+ });
130
+ }
131
+ }
132
+
133
+ for (const { tools, policy } of [
134
+ ...[undefined, "off"].flatMap(policy => [["read", "unknown_tool"], []].map(tools => ({ tools, policy }))),
135
+ { tools: ["read", "bash_background", "grep"], policy: "on" },
136
+ ]) {
137
+ test(`background capability admission preserves launch for ${tools.join(",") || "default tools"} with ${policy ?? "default off"} policy`, async (t) => {
138
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
139
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
140
+ const h = harness();
141
+ const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), ...(policy === undefined ? {} : { GENTLE_PI_BACKGROUND_SUBAGENTS: policy }), KEEP: "yes" };
142
+ const task = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools } }));
143
+ await tick();
144
+ assert.equal(h.store.get(task.id)?.status, TASK_STATUS.RUNNING);
145
+ assert.equal(h.store.get(task.id)?.error, null);
146
+ assert.equal(h.children.length, 1);
147
+ const expected = tools.length ? [...tools, "subagent_parent_message"].join(",") : undefined;
148
+ assert.equal(h.spawnOptions[0].env[REQUESTED_TOOLS_ENV], expected);
149
+ assert.equal(h.spawnOptions[0].env.KEEP, "yes");
150
+ assert.deepEqual(h.spawnOptions[0].args, childArguments(request({ cwd: root, env, agent: { ...explorer, tools } })));
151
+ assert.equal(h.timers.filter(timer => !timer.cancelled).length, 1);
152
+ h.runner.cancel(task.id);
153
+ await tick();
154
+ });
155
+ }
156
+
157
+ test("background capability admission refreshes project policy when queued work dispatches", async (t) => {
158
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
159
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
160
+ const dir = join(root, ".pi", "gentle-ai");
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ const file = join(dir, "background-subagents.json");
163
+ const h = harness({ maxConcurrency: 1 });
164
+ const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), GENTLE_PI_BACKGROUND_SUBAGENTS: "on" };
165
+ const first = h.runner.run(request({ cwd: root, env }));
166
+ const queued = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools: ["bash_background"] } }));
167
+ await tick();
168
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.QUEUED);
169
+ const raw = '{"schema":"gentle-pi.background-subagents/v1","policy":"off"}';
170
+ fs.writeFileSync(file, raw);
171
+ h.runner.cancel(first.id);
172
+ await tick();
173
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.FAILED);
174
+ assert.equal(h.store.get(queued.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${file} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
175
+ assert.equal(h.children.length, 1);
176
+ assert.equal(readFileSync(file, "utf8"), raw);
177
+ });
178
+
98
179
  const FOUR_MIN_MS = 4 * 60_000;
99
180
 
100
181
  function argumentUpdate(type: string, fields: Record<string, unknown> = {}): Record<string, unknown> {
@@ -923,7 +1004,8 @@ test("AgentRunner runs a task end to end: prompt, deltas into the store, complet
923
1004
  assert.equal(finished?.toolCalls, 1);
924
1005
  assert.equal(finished?.sessionPath, "/sessions/child.jsonl");
925
1006
  assert.equal(finished?.label, "Map the repo");
926
- assert.ok(children[0].killed.length > 0, "the child is stopped once the answer is in");
1007
+ assert.ok(children[0].child.stdin.writableEnded, "the child is stopped via orderly shutdown once the answer is in");
1008
+ assert.equal(children[0].killed.length, 0, "no kill signals sent during orderly settlement");
927
1009
  assert.equal(store.thread(task.id).items.length, 2);
928
1010
  assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
929
1011
  });
@@ -973,6 +1055,143 @@ test("AgentRunner queues beyond max concurrency and starts the next task when on
973
1055
  assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING);
974
1056
  });
975
1057
 
1058
+ test("AgentRunner requests orderly shutdown via stdin on settlement and does not send SIGTERM immediately (#1740)", async () => {
1059
+ const { store, runner, children } = harness({ exitOnKill: false });
1060
+ const task = runner.run(request());
1061
+ await tick();
1062
+ assert.equal(children.length, 1);
1063
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1064
+ await tick();
1065
+ assert.equal(children[0].killed.length, 0);
1066
+ children[0].emit({ type: "agent_settled" });
1067
+ await tick();
1068
+
1069
+ // Orderly shutdown must have requested stdin.end()
1070
+ assert.equal(children[0].child.stdin.writableEnded, true, "stdin must be ended on settlement to trigger Pi orderly shutdown");
1071
+ // Must NOT send SIGTERM immediately
1072
+ assert.deepEqual(children[0].killed, [], "SIGTERM must not be sent immediately upon settlement");
1073
+ // Task completion and cleanup in flight should be distinguishable
1074
+ assert.equal(store.get(task.id)?.lastStep, "cleaning up", "lastStep distinguishes in-flight cleanup");
1075
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "task waits for cleanup exit");
1076
+
1077
+ // Child exits cleanly upon session_shutdown completion
1078
+ children[0].exit(0);
1079
+ await tick();
1080
+ await tick();
1081
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
1082
+ assert.equal(store.get(task.id)?.lastStep, "done");
1083
+ assert.deepEqual(children[0].killed, [], "clean shutdown completes with zero kill signals sent");
1084
+ });
1085
+
1086
+ test("AgentRunner allows delayed cleanup and cancels escalation after clean exit (#1740)", async () => {
1087
+ // EOF must be independent of kill behavior: this child honors signals but
1088
+ // needs time to finish asynchronous shutdown after its input closes.
1089
+ const { store, runner, children, timers, advance, finishes } = harness({ exitOnStdinEnd: false });
1090
+ const task = runner.run(request());
1091
+ await tick();
1092
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1093
+ children[0].emit({ type: "agent_settled" });
1094
+ await tick();
1095
+ assert.equal(children[0].child.stdin.writableEnded, true);
1096
+ advance(2_000);
1097
+ await tick();
1098
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING);
1099
+ assert.equal(store.get(task.id)?.lastStep, "cleaning up");
1100
+ assert.deepEqual(children[0].killed, []);
1101
+ assert.deepEqual(finishes, []);
1102
+ children[0].exit(0);
1103
+ await tick();
1104
+ assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
1105
+ assert.equal(store.get(task.id)?.lastStep, "done");
1106
+ assert.deepEqual(finishes, [task.id]);
1107
+ assert.ok(timers.filter((timer) => timer.ms === 5_000).every((timer) => timer.cancelled));
1108
+ advance(5_000);
1109
+ await tick();
1110
+ assert.deepEqual(children[0].killed, []);
1111
+ assert.deepEqual(finishes, [task.id]);
1112
+ });
1113
+
1114
+ test("AgentRunner escalates to SIGTERM and SIGKILL if orderly cleanup exceeds cleanupTimeoutMs (#1740)", async () => {
1115
+ const { store, runner, children, timers } = harness({ exitOnKill: false, cleanupTimeoutMs: 3000 });
1116
+ const task = runner.run(request());
1117
+ await tick();
1118
+ assert.equal(children.length, 1);
1119
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1120
+ await tick();
1121
+ children[0].emit({ type: "agent_settled" });
1122
+ await tick();
1123
+
1124
+ assert.equal(children[0].child.stdin.writableEnded, true);
1125
+ assert.deepEqual(children[0].killed, []);
1126
+
1127
+ // Find the cleanup timeout timer (3000 ms) and trigger it
1128
+ const cleanupTimer = timers.find((timer) => !timer.cancelled && timer.ms === 3000);
1129
+ assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
1130
+ cleanupTimer.fn();
1131
+ await tick();
1132
+
1133
+ // Escalation has fired SIGTERM
1134
+ assert.deepEqual(children[0].killed, ["SIGTERM"]);
1135
+
1136
+ // And termination grace timer (250 ms) is scheduled for SIGKILL
1137
+ const killTimer = timers.find((timer) => !timer.cancelled && timer.ms === 250);
1138
+ assert.ok(killTimer, "SIGKILL escalation timer must be scheduled");
1139
+ killTimer.fn();
1140
+ await tick();
1141
+
1142
+ assert.deepEqual(children[0].killed, ["SIGTERM", "SIGKILL"]);
1143
+ });
1144
+
1145
+ test("orderly cleanup escalation retains the full exit confirmation deadline after SIGKILL (#1740)", async () => {
1146
+ let groupAlive = true;
1147
+ const { store, runner, children, timers, advance } = harness({
1148
+ exitOnKill: false,
1149
+ pid: 42,
1150
+ cleanupTimeoutMs: 5_000,
1151
+ process: {
1152
+ platform: "linux",
1153
+ kill: () => {
1154
+ if (!groupAlive) throw Object.assign(new Error(), { code: "ESRCH" });
1155
+ return true;
1156
+ },
1157
+ },
1158
+ });
1159
+ const task = runner.run(request());
1160
+ await tick();
1161
+ assert.equal(children.length, 1);
1162
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1163
+ await tick();
1164
+ children[0].emit({ type: "agent_settled" });
1165
+ await tick();
1166
+
1167
+ // Advance time by the cleanup window (5000 ms)
1168
+ const cleanupTimer = timers.find((t) => !t.cancelled && t.ms === 5_000);
1169
+ assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
1170
+ advance(5_000);
1171
+ await tick();
1172
+
1173
+ // Advance time by termination grace (250 ms)
1174
+ const killTimer = timers.find((t) => !t.cancelled && t.ms === 250);
1175
+ assert.ok(killTimer, "SIGKILL timer must be scheduled");
1176
+ advance(250);
1177
+ await tick();
1178
+
1179
+ // After SIGKILL, confirmGroupExit checks the process group. Because cleanupDeadlineAt was set
1180
+ // at SIGKILL time, it must NOT immediately fail with capacity quarantined!
1181
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "must not prematurely quarantine after cleanup window");
1182
+ const probeTimer = timers.find((t) => !t.cancelled && t.ms === 25);
1183
+ assert.ok(probeTimer, "exit confirmation polling timer must be scheduled");
1184
+
1185
+ // If the group exits during the confirmation window:
1186
+ groupAlive = false;
1187
+ children[0].exit(0);
1188
+ advance(25);
1189
+ await tick();
1190
+
1191
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
1192
+ assert.equal(store.get(task.id)?.error, null);
1193
+ });
1194
+
976
1195
  test("AgentRunner classifies terminal assistant outcomes only after settlement", async () => {
977
1196
  const scenarios = [
978
1197
  { name: "error", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error: secret=never-copy" }], status: TASK_STATUS.FAILED, error: /assistant reported an error/ },
@@ -1136,7 +1355,9 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
1136
1355
  const launch = spawnOptions[0];
1137
1356
  const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
1138
1357
  assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
1139
- assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", TMPDIR: launch?.env.TMPDIR });
1358
+ const scratch = launch?.env.TMPDIR;
1359
+ const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
1360
+ assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
1140
1361
  assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
1141
1362
  assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
1142
1363
  assert.equal(launch?.stdio?.length, 5);
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { once } from "node:events";
5
5
  import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
6
- import { createConnection, createServer, Socket } from "node:net";
6
+ import { createConnection, createServer, Socket, type Server } from "node:net";
7
7
  import { join } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
9
  import nodeTest, { type TestContext } from "node:test";
@@ -124,7 +124,12 @@ test("lists only advertised peer IDs without mutating the private registry", asy
124
124
  assert.equal(await snapshot(), before, "listing leaves presence files, directory modes, and socket entries unchanged");
125
125
  assert.equal(alphaNew.createdAt >= alphaOld.createdAt, true);
126
126
  for (let index = 0; index < 65; index++) await transport.publish(await transport.record(`overflow-${index}`, index));
127
- await rejected(transport.list(), "busy");
127
+ // The over-cap scan sweeps provably dead records once instead of failing
128
+ // busy: the overflow endpoints were never bound, and the missing/malformed/
129
+ // symlinked fixtures are debris too. Live peers stay listed.
130
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "alpha", reachability: "unknown" }]);
131
+ const remaining = (await readdir(transport.paths.presence)).sort();
132
+ assert.equal(remaining.length, 3, "only the live records (self, alpha twice) survive the sweep");
128
133
  });
129
134
 
130
135
  test("keeps same-session activations distinct and old cleanup preserves the newest", async (t) => {
@@ -161,7 +166,52 @@ test("isolates two sessions and bounds registry discovery at 65 entries", async
161
166
  assert.deepEqual(await transport.resolve("left"), left);
162
167
  assert.deepEqual(await transport.resolve("right"), right);
163
168
  for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index));
164
- await rejected(transport.resolve("capped"), "busy");
169
+ // The dead-endpoint overflow is swept on the first over-cap scan, so the
170
+ // retry resolves the truth (nothing left) instead of failing busy.
171
+ await rejected(transport.resolve("capped"), "not_found");
172
+ assert.equal((await readdir(transport.paths.presence)).length, 0, "dead overflow records are swept, never retained");
173
+ });
174
+
175
+ test("foreign-schema presence files never count toward the activation cap", async (t) => {
176
+ const transport = await registry(t);
177
+ // The orchestrator publisher shares this directory with a different naming
178
+ // schema (<sessionHash>.<incarnation>.<kind>.json, three files per peer),
179
+ // and long-lived profiles accumulate hundreds of those records. They are
180
+ // never transport activations, so they must not trip the spam guard.
181
+ const incarnation = (index: number) => `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`;
182
+ for (let index = 0; index < 80; index++) {
183
+ for (const kind of ["header", "activity", "discovery"]) {
184
+ await writeFile(join(transport.paths.presence, `${"b".repeat(64)}.${incarnation(index)}.${kind}.json`), "{}");
185
+ }
186
+ }
187
+ const self = await raw(t, transport, "self", null);
188
+ const peer = await raw(t, transport, "peer", null);
189
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "listing survives a foreign-schema flood and still sees real activations");
190
+ assert.deepEqual(await transport.resolve("peer"), peer);
191
+ // The cap still bounds real transport activations exactly as before: the
192
+ // 65 advertised dead-endpoint records overflow the guard, the sweep removes
193
+ // them, and the live peers remain discoverable afterwards.
194
+ for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index + 10));
195
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "dead overflow is swept once and the live peer stays discoverable");
196
+ await rejected(transport.resolve("capped"), "not_found");
197
+ });
198
+
199
+ // The sweep must never trade the spam guard for liveness probing: a genuine
200
+ // flood of records backed by real listening sockets stays over the cap, keeps
201
+ // failing busy, and its records are never removed.
202
+ test("a flood of live listeners stays over the cap and keeps failing busy", async (t) => {
203
+ const transport = await registry(t);
204
+ const servers: Server[] = [];
205
+ t.after(() => { for (const server of servers) server.close(); });
206
+ for (let index = 0; index < 66; index++) {
207
+ const record = await transport.record(`flood-${index}`, index);
208
+ const server = createServer(() => {});
209
+ await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, resolveListen); });
210
+ servers.push(server);
211
+ await transport.publish(record);
212
+ }
213
+ await rejected(transport.list(), "busy");
214
+ assert.equal((await readdir(transport.paths.presence)).length, 66, "live records are never swept");
165
215
  });
166
216
 
167
217
  test("rejects unsafe IDs, v1 schema violations, prototypes, and content-leaking errors", async (t) => {
@@ -33,6 +33,7 @@ interface ChoiceTool {
33
33
  promptGuidelines?: string[];
34
34
  parameters: ChoiceParameters;
35
35
  execute: (...args: unknown[]) => Promise<ChoiceResult>;
36
+ renderResult?: (result: unknown, options: unknown, theme: ChoiceTheme) => { render(width: number): string[] };
36
37
  }
37
38
 
38
39
  interface ChoiceLifecycleEvent {
@@ -711,3 +712,32 @@ test("ask_user_choice is offered on an interactive rpc host and withdrawn once t
711
712
  for (const hook of registration.hooks) await hook({}, { mode: "rpc" });
712
713
  assert.deepEqual(registration.activeTools(), ["read"]);
713
714
  });
715
+
716
+ test("ask_user_choice renderResult renders selected, custom response with text, and cancelled states (#1692)", () => {
717
+ const registration = registerChoiceTool();
718
+ const theme: ChoiceTheme = { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text };
719
+
720
+ // 1. Selected option
721
+ const selected = registration.tool.renderResult!(
722
+ { details: { selection: { index: 1, label: "Deploy to production" } } },
723
+ {},
724
+ theme,
725
+ ).render(80).join("\n");
726
+ assert.match(selected, /✓ 1\. Deploy to production/);
727
+
728
+ // 2. Custom response with submitted text (#1692)
729
+ const custom = registration.tool.renderResult!(
730
+ { details: { customResponse: "use the staging bucket" } },
731
+ {},
732
+ theme,
733
+ ).render(80).join("\n");
734
+ assert.match(custom, /✓ Custom response — use the staging bucket/);
735
+
736
+ // 3. Cancelled
737
+ const cancelled = registration.tool.renderResult!(
738
+ { details: { cancelled: true } },
739
+ {},
740
+ theme,
741
+ ).render(80).join("\n");
742
+ assert.match(cancelled, /Cancelled/);
743
+ });
@@ -42,6 +42,23 @@ test("Auto cannot bypass data-loss, configured confirmation, protected paths or
42
42
  assert.equal(h.requests.length,0);
43
43
  });
44
44
 
45
+ test("production hook classifies companion updates without granting permission to change authority",async t=>{
46
+ const h=harness(t);await h.enable();await h.input("Update the installed companion tools from main; preserve permissions.");
47
+ for(const toolName of ["write","edit"]) {
48
+ assert.equal(await h.call(toolName,{path:join(h.home,"dev-binary.json"),content:"fixture companion update"}),undefined);
49
+ }
50
+ assert.equal(h.requests.length,2,"updates reach the ordinary Auto classifier");
51
+ h.setDecision("deny");
52
+ assert.equal((await h.call("write",{path:join(h.home,"channel.json"),content:"unapproved"})).block,true);
53
+ assert.equal(h.requests.length,3);
54
+ h.setDecision("allow");
55
+ for(const name of ["permission-mode.json","runtime-guardrails.json","models.json","persona.json"]) {
56
+ assert.equal((await h.call("write",{path:join(h.home,name),content:"{}"})).block,true);
57
+ }
58
+ assert.equal(h.requests.length,3,"a classifier allow cannot override protected authority");
59
+ assert.equal(h.confirmations(),0);
60
+ });
61
+
45
62
  test("normal mode preserves previous guard behavior and named children do not inherit Auto",async t=>{
46
63
  const h=harness(t);assert.equal((await h.call("bash",{command:"git push origin feature"})).block,true);assert.equal(h.requests.length,0);
47
64
  await h.enable();await h.emit("before_agent_start",{agent:{name:"worker"},systemPromptOptions:{}});
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AutoPermissionMemory } from "../lib/auto-permission-memory.ts";
4
+ test("binder diagnostics retain rejection cause without destination or content",()=>{
5
+ const memory=new AutoPermissionMemory(()=>[]);
6
+ const ctx:any={cwd:"PRIVATE_CWD",sessionManager:{getSessionId:()=>"PRIVATE_SESSION",getBranch:()=>[{id:"PRIVATE_ANCHOR"}]}};
7
+ memory.admitted({toolName:"mem_current_project",toolCallId:"PRIVATE_CALL",input:{cwd:ctx.cwd}},ctx,1);
8
+ memory.context({toolName:"mem_save",input:{cwd:ctx.cwd,project:"PRIVATE_PROJECT",content:"PRIVATE_CONTENT"}},ctx,1);
9
+ assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection:"tool-missing"});
10
+ assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
11
+ memory.clear();
12
+ memory.context({toolName:"mem_session_summary",input:{}},ctx,1);
13
+ assert.deepEqual(memory.diagnostic(),{stage:"clear",reason:"binding-cleared"});
14
+ });
15
+
16
+ test("source rejection reports only the first failed provenance predicate",()=>{
17
+ const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram@latest",scope:"user",origin:"package"};
18
+ const tool={name:"mem_current_project",sourceInfo};
19
+ const cases:[string,any[]][]=[
20
+ ["tool-missing",[]],["tool-ambiguous",[tool,tool]],
21
+ ["metadata-missing",[{name:tool.name}]],
22
+ ["origin",[{...tool,sourceInfo:{...sourceInfo,origin:"top-level",source:"local"}}]],
23
+ ["source-format",[{...tool,sourceInfo:{...sourceInfo,source:"PRIVATE_SOURCE"}}]],
24
+ ["path",[{...tool,sourceInfo:{...sourceInfo,path:"<PRIVATE_PATH>"}}]],
25
+ ];
26
+ const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
27
+ const call={toolName:tool.name,toolCallId:"resolve",input:{cwd:ctx.cwd}};
28
+ for(const [sourceRejection,tools] of cases){
29
+ const memory=new AutoPermissionMemory(()=>tools);
30
+ memory.admitted(call,ctx,1);
31
+ assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection});
32
+ memory.context({toolName:"mem_save",input:{}},ctx,1);
33
+ assert.equal(memory.diagnostic()?.sourceRejection,sourceRejection);
34
+ assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
35
+ }
36
+ const memory=new AutoPermissionMemory(()=>[tool] as any);
37
+ memory.admitted(call,ctx,1);
38
+ assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"admitted"});
39
+ });
40
+
41
+ test("registry exceptions replace successful attachment diagnostics",()=>{
42
+ let fail=false;
43
+ const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram",scope:"user",origin:"package"};
44
+ const memory=new AutoPermissionMemory(()=>{if(fail)throw Error("PRIVATE_ERROR");return ["mem_current_project","mem_save"].map(name=>({name,sourceInfo})) as any;});
45
+ const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
46
+ const call={toolName:"mem_current_project",toolCallId:"resolve",input:{cwd:ctx.cwd}};
47
+ memory.admitted(call,ctx,1);
48
+ memory.result({...call,isError:false,details:{data:{cwd:ctx.cwd,project:"project",project_source:"git_remote"}}} as any,ctx,1);
49
+ const save={toolName:"mem_save",input:{cwd:ctx.cwd,project:"project"}};
50
+ assert.ok(memory.context(save,ctx,1));
51
+ assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"attached"});
52
+ fail=true;
53
+ assert.equal(memory.context(save,ctx,1),undefined);
54
+ assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"unexpected-error"});
55
+ assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
56
+ });