gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -183,7 +183,6 @@ interface ParityRuntimeOptions {
183
183
  pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
184
184
  now?: () => number;
185
185
  scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
186
- writeConsentLatch?: (cwd: string) => void;
187
186
  }
188
187
 
189
188
  function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime {
@@ -197,7 +196,7 @@ function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityR
197
196
  now: options.now,
198
197
  scheduleTimer: options.scheduleTimer,
199
198
  } as unknown as Parameters<typeof createGentleAiExtension>[0];
200
- __testing.createGentleAiExtension(dependencies, options.writeConsentLatch ?? (() => {}))({
199
+ __testing.createGentleAiExtension(dependencies)({
201
200
  on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
202
201
  registerTool(definition: RegisteredControllerTool & { name: string }) { tools.set(definition.name, definition); },
203
202
  registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
@@ -401,7 +400,7 @@ test("public gentle:review-mode handler reports current operations, global-off w
401
400
  assert.deepEqual(unavailableNotices, [{ message: "Gentle AI review mode is not available with the currently negotiated native version.", type: "info" }]);
402
401
  });
403
402
 
404
- test("public consent relay is session-bound, one-shot, and candidate-scoped", async (t) => {
403
+ test("public consent relay allows any active session to resolve a live binding, is one-shot, and is candidate-scoped", async (t) => {
405
404
  const cwd = repository(t);
406
405
  const sharedRegistry = new PendingReviewConsentRegistry();
407
406
  const fixture = consentNative(cwd);
@@ -411,14 +410,22 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
411
410
  const sameSessionResult = await answerConsent(second, cwd, sameSession.consent_binding, "declined", "same-session");
412
411
  assert.equal(sameSessionResult.outcome, "consent-declined-this-candidate");
413
412
 
413
+ // gentle-pi#455: a binding is answerable by whichever active Pi session
414
+ // presents it, not only the session whose START created it.
414
415
  const blockedA = await beginConsent(first, cwd, "session-a");
415
416
  assert.deepEqual(blockedA.consent, decodeReviewConsentV3(captured("consent-v3.captured.json")).raw);
416
- const staleOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
417
- assert.equal(staleOtherSession.operation, "answer-consent");
418
- // gentle-pi#516: a binding this session does not hold must never read as
419
- // a healthy pre-start STATUS; it names itself and the exit.
420
- assertStaleConsentBinding(staleOtherSession, blockedA.consent_binding, "consent-binding-unknown");
421
- assert.deepEqual(fixture.answers, ["declined"]);
417
+ const declinedFromOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
418
+ assert.equal(declinedFromOtherSession.operation, "answer-consent");
419
+ assert.equal(declinedFromOtherSession.outcome, "consent-declined-this-candidate");
420
+ assert.deepEqual(fixture.answers, ["declined", "declined"]);
421
+
422
+ // Cross-session resolution still enforces single use: once answered, no
423
+ // session -- including the one whose START created it -- may answer it again.
424
+ const staleAfterCrossSessionAnswer = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
425
+ assert.equal(staleAfterCrossSessionAnswer.operation, "answer-consent");
426
+ assertStaleConsentBinding(staleAfterCrossSessionAnswer, blockedA.consent_binding, "consent-binding-already-consumed");
427
+ assert.deepEqual(fixture.answers, ["declined", "declined"]);
428
+
422
429
  const blockedB = await beginConsent(second, cwd, "session-b");
423
430
  const declined = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
424
431
  assert.equal(declined.outcome, "consent-declined-this-candidate");
@@ -426,15 +433,19 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
426
433
  const staleConsumed = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
427
434
  assert.equal(staleConsumed.operation, "answer-consent");
428
435
  assertStaleConsentBinding(staleConsumed, blockedB.consent_binding, "consent-binding-already-consumed");
429
- assert.deepEqual(fixture.answers, ["declined", "declined"]);
436
+ assert.deepEqual(fixture.answers, ["declined", "declined", "declined"]);
430
437
 
438
+ // A binding still pending when its owning session shuts down is discarded
439
+ // and becomes unreachable to every session -- cross-session resolution
440
+ // included.
441
+ const blockedShutdown = await beginConsent(first, cwd, "session-a");
431
442
  const shutdown = first.events.get("session_shutdown");
432
443
  assert.ok(shutdown);
433
444
  await shutdown!({}, context(cwd, "session-a"));
434
445
  await shutdown!({}, context(cwd, "session-a"));
435
- const staleShutdown = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
446
+ const staleShutdown = await answerConsent(second, cwd, blockedShutdown.consent_binding, "declined", "session-b");
436
447
  assert.equal(staleShutdown.operation, "answer-consent");
437
- assertStaleConsentBinding(staleShutdown, blockedA.consent_binding, "consent-binding-unknown");
448
+ assertStaleConsentBinding(staleShutdown, blockedShutdown.consent_binding, "consent-binding-unknown");
438
449
 
439
450
  writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
440
451
  const nextCandidate = await beginConsent(second, cwd, "session-b");
@@ -450,7 +461,7 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
450
461
  const staleModeCleared = await answerConsent(second, cwd, nextCandidate.consent_binding, "declined", "session-b");
451
462
  assert.equal(staleModeCleared.operation, "answer-consent");
452
463
  assertStaleConsentBinding(staleModeCleared, nextCandidate.consent_binding, "consent-binding-unknown");
453
- assert.equal(fixture.starts.count, 4);
464
+ assert.equal(fixture.starts.count, 5);
454
465
  });
455
466
 
456
467
  test("already-consumed local consent bindings reconcile exactly once with the native status transition", async (t) => {
@@ -500,13 +511,18 @@ test("launched answer-consent failures remain blocked after fresh-target STATUS
500
511
  let answerCalls = 0;
501
512
  fixture.native.answerConsent = async () => {
502
513
  answerCalls += 1;
503
- throw new NativeReviewCliError(
504
- NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
505
- NATIVE_REVIEW_OPERATION.START,
506
- true,
507
- true,
508
- "native review/start failed after launch",
509
- );
514
+ throw {
515
+ name: "NativeReviewCliError",
516
+ code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
517
+ mutationOutcome: "unknown",
518
+ nextAction: "review.status",
519
+ diagnostics: {
520
+ operation: NATIVE_REVIEW_OPERATION.START,
521
+ error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
522
+ timed_out: false,
523
+ output_limit_exceeded: false,
524
+ },
525
+ };
510
526
  };
511
527
  const runtime = parityRuntime(fixture.native);
512
528
  const blocked = await beginConsent(runtime, cwd);
@@ -522,6 +538,8 @@ test("launched answer-consent failures remain blocked after fresh-target STATUS
522
538
  output_limit_exceeded: false,
523
539
  });
524
540
  assert.deepEqual(first.reconciliation, freshTargetStatus.raw);
541
+ assert.match(String(first.required_status_action), /Run target-scoped review\.status/);
542
+ assert.equal(first.next_action, "start");
525
543
  assert.equal(statusRequests.length, 2, "the launched failure reconciles exactly once after the initial START status");
526
544
  assert.equal(answerCalls, 1, "reconciliation must not replay answer-consent");
527
545
 
@@ -783,18 +801,14 @@ test("native START maps only provider facts and omits absent evidence", async (t
783
801
  assert.equal(rendered.next_transition, nextTransition);
784
802
  });
785
803
 
786
- test("consent completion stays authoritative when local latch recording fails", async (t) => {
804
+ test("candidate consent completion writes no persistent asked latch", async (t) => {
787
805
  const cwd = repository(t);
788
806
  const fixture = consentNative(cwd);
789
- const notices: Array<{ message: string; type?: string }> = [];
790
- const runtime = parityRuntime(fixture.native, {
791
- writeConsentLatch: () => { throw new Error("injected latch failure"); },
792
- });
807
+ const runtime = parityRuntime(fixture.native);
793
808
  const blocked = await beginConsent(runtime, cwd);
794
809
  const completed = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
795
810
  assert.equal((completed.result as { lineage_id?: string }).lineage_id, "consent-lineage");
796
811
  assert.deepEqual(fixture.answers, ["granted"]);
797
- assert.deepEqual(notices, []);
798
- // The extension handler owns UI reporting; the controller's successful native
799
- // result remains authoritative even when the local best-effort latch fails.
812
+ const commonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8" }).trim();
813
+ assert.equal(existsSync(join(commonDir, "gentle-pi", "review-consent", "asked.json")), false);
800
814
  });
@@ -147,7 +147,6 @@ test("workflow settle command line binds mandatory arguments and routing invaria
147
147
  "--token <token>",
148
148
  "--request-id <id>",
149
149
  "--outcome <failed|interrupted|passed>",
150
- "--evidence-revision <sha256:...>",
151
150
  "--diagnosis <text>",
152
151
  "--harness-disposition <reused|invalidated>",
153
152
  "--cleanup-evidence <text>",
@@ -155,7 +154,13 @@ test("workflow settle command line binds mandatory arguments and routing invaria
155
154
  ]) {
156
155
  assert.ok(settle.includes(arg), `settle command is missing ${arg}; command: ${settle}`);
157
156
  }
158
- assert.match(section, /never `none`/);
157
+ assert.ok(settle.includes("[--evidence-revision <sha256:...>]"));
158
+ assert.match(section, /Every settle field except `evidence-revision` is required/);
159
+ assert.match(
160
+ section,
161
+ /For `failed` or `passed`, include `--evidence-revision` with the `sha256:\.\.\.` evidence hash\./,
162
+ );
163
+ assert.match(section, /For `interrupted`, omit the entire `--evidence-revision` flag\./);
159
164
  assert.match(section, /proceed\|blocked\|complete/);
160
165
  assert.match(section, /--successor-lineage/);
161
166
  assert.match(section, /--remediates-evidence-revision/);
@@ -114,8 +114,21 @@ function measureOrchestratorPromptBytes(assetsDir: string): number {
114
114
  // 2.2 — Byte budget (Spec: Always-On Injection Byte Budget)
115
115
  // ---------------------------------------------------------------------------
116
116
 
117
+ // gentle-pi#661: `renderOrchestratorPrompt`/`getOrchestratorPrompt` default
118
+ // `rddStatusLine` to the "unknown (native status unavailable)" line -- the
119
+ // longest of the three renderable RDD status lines -- precisely so that a
120
+ // no-argument call renders the worst case this budget measures, not a
121
+ // smaller placeholder that production would later exceed. Assert that line
122
+ // is actually present so a future default change cannot silently start
123
+ // measuring a shorter render again.
124
+ const RDD_WORST_CASE_LINE = "Receipt-driven development: unknown (native status unavailable)";
125
+
117
126
  test("getOrchestratorPrompt return value stays within the canonical 8,192 B budget at a short assets root", () => {
118
127
  const rendered = __testing.renderOrchestratorPrompt(representativeProductionAssetsDir);
128
+ assert.ok(
129
+ rendered.includes(RDD_WORST_CASE_LINE),
130
+ "the default render must include the worst-case RDD status line to measure the real production budget",
131
+ );
119
132
  const bytes = Buffer.byteLength(rendered, "utf8");
120
133
  assert.ok(
121
134
  bytes <= BUDGET_BYTES,
@@ -125,6 +138,10 @@ test("getOrchestratorPrompt return value stays within the canonical 8,192 B budg
125
138
 
126
139
  test(`getOrchestratorPrompt keeps a controlled long (>= ${MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS} char) assets root within the canonical budget`, () => {
127
140
  const rendered = __testing.renderOrchestratorPrompt(controlledLongAssetsDir);
141
+ assert.ok(
142
+ rendered.includes(RDD_WORST_CASE_LINE),
143
+ "the default render must include the worst-case RDD status line to measure the real production budget",
144
+ );
128
145
  const bytes = measureOrchestratorPromptBytes(controlledLongAssetsDir);
129
146
  assert.equal(bytes, Buffer.byteLength(rendered, "utf8"), "child-process and direct render byte counts must match");
130
147
  assert.ok(
@@ -420,3 +437,55 @@ test("getOrchestratorPrompt memoizes the return across calls", () => {
420
437
  const second = __testing.getOrchestratorPrompt();
421
438
  assert.equal(second, first, "second call must return the memoized string");
422
439
  });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // gentle-pi#661 follow-up: byte-budget compression must not turn a pointer
443
+ // into an opaque "Detail: `file.md`" -- each pointer line must still name
444
+ // the lazy-loaded material it points to, in the shortest form that still
445
+ // says what is over there.
446
+ // ---------------------------------------------------------------------------
447
+
448
+ test("every compressed lazy-file pointer in the core still names the material it points to", () => {
449
+ const core = readFileSync(join(REAL_ASSETS_DIR, "orchestrator.md"), "utf8");
450
+ // One-sentence "<named material>: `file.md`." pointers, compressed for the
451
+ // byte budget -- each must keep naming what is over there, not collapse
452
+ // to an opaque "Detail: `file.md`.".
453
+ const namedPointers: ReadonlyArray<{ file: string; mustName: readonly string[] }> = [
454
+ {
455
+ file: "orchestrator-delegation.md",
456
+ mustName: ["Per-action table", "Work Routing Ladder", "Canonical Workflows", "blocking-prompt relays"],
457
+ },
458
+ {
459
+ file: "orchestrator-memory.md",
460
+ mustName: ["Phase table", "artifact keys"],
461
+ },
462
+ {
463
+ file: "orchestrator-skills.md",
464
+ mustName: ["Discovery order", "intent hints"],
465
+ },
466
+ ];
467
+ for (const { file, mustName } of namedPointers) {
468
+ // The pointer sentence: from the previous sentence boundary up to and
469
+ // including the backtick-quoted filename. `orchestrator-delegation.md`
470
+ // is referenced more than once in the core (a language-boundary pointer
471
+ // earlier, this compressed per-action pointer later) -- take the LAST
472
+ // occurrence, which is the one under test here.
473
+ const fileToken = `\`${file}\``;
474
+ const fileIndex = core.lastIndexOf(fileToken);
475
+ assert.ok(fileIndex >= 0, `core is missing a pointer to ${file}`);
476
+ const sentenceStart = core.lastIndexOf(".", fileIndex);
477
+ const pointerSentence = core.slice(sentenceStart + 1, fileIndex + fileToken.length);
478
+ for (const name of mustName) {
479
+ assert.ok(
480
+ pointerSentence.includes(name),
481
+ `the pointer to ${file} must name "${name}", got: ${JSON.stringify(pointerSentence.trim())}`,
482
+ );
483
+ }
484
+ }
485
+ // The SDD workflow pointer was never compressed to a bare filename; its
486
+ // surrounding paragraphs already name the material at length.
487
+ assert.match(
488
+ core,
489
+ /SDD phases, native dispatcher rules, status contract, preflight\/init guards, artifact-store policy, execution mode, Strict TDD forwarding, phase result contract, and review workload guard/,
490
+ );
491
+ });
@@ -0,0 +1,389 @@
1
+ import assert from "node:assert/strict";
2
+ import { fork } from "node:child_process";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import * as fs from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import test from "node:test";
8
+ import { historyDir, saveTask } from "../lib/agents-history.ts";
9
+ import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
10
+ import * as presence from "../lib/orchestrator-presence.ts";
11
+ import {
12
+ ACTIVITY_LIMIT, PresencePublisher, listPresence, projectActivity, readActivity,
13
+ } from "../lib/orchestrator-presence.ts";
14
+
15
+ function fixture(t: test.TestContext) {
16
+ const profile = fs.mkdtempSync(join(fs.realpathSync(tmpdir()), "presence-test-"));
17
+ // Each fixture owns precisely these files; never traverse a linked directory.
18
+ t.after(() => {
19
+ const root = join(profile, "gentle-agents");
20
+ if (fs.existsSync(root) && !fs.lstatSync(root).isSymbolicLink()) {
21
+ for (const name of fs.readdirSync(root)) {
22
+ const path = join(root, name);
23
+ if (["tasks", "presence"].includes(name) && fs.lstatSync(path).isDirectory()) {
24
+ for (const file of fs.readdirSync(path)) fs.unlinkSync(join(path, file));
25
+ fs.rmdirSync(path);
26
+ } else fs.unlinkSync(path);
27
+ }
28
+ fs.rmdirSync(root);
29
+ } else if (fs.existsSync(root)) fs.unlinkSync(root);
30
+ for (const name of fs.readdirSync(profile)) fs.unlinkSync(join(profile, name));
31
+ fs.rmdirSync(profile);
32
+ });
33
+ return profile;
34
+ }
35
+
36
+ function rows(text = "full retained output") {
37
+ return [{ task: { id: "t", agent: "worker", label: "summary", status: "running", model: "model",
38
+ createdAt: 1000, startedAt: 1100, endedAt: null, lastActivityAt: 1200,
39
+ prompt: "PRIVATE_PROMPT", sessionPath: "/private/session", cwd: "/private/cwd", cancel: "CAPABILITY" },
40
+ thread: { version: 1, dropped: 2, items: [
41
+ { kind: "text", text }, { kind: "thinking", text: "reasoning" }, { kind: "note", text: "note" },
42
+ { kind: "tool", callId: "c", name: "read", output: "complete tool output", running: false,
43
+ isError: false, args: { prompt: "PRIVATE_PROMPT" }, sessionPath: "/private/session" },
44
+ ] } }];
45
+ }
46
+
47
+ function realTask(): TaskRecord {
48
+ return { ...rows()[0].task, status: TASK_STATUS.RUNNING, mode: "task", parentSessionId: "same session",
49
+ thinking: undefined, error: null, result: null, lastStep: "read", turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
50
+ }
51
+
52
+ function publisher(t: test.TestContext, profile: string, activity = rows()) {
53
+ const value = PresencePublisher.start({ profile, sessionId: "same session", label: "Workspace", activity });
54
+ t.after(() => value.dispose());
55
+ return value;
56
+ }
57
+
58
+ function paths(profile: string, header: { sessionHash: string; incarnation: string }) {
59
+ const stem = `${header.sessionHash}.${header.incarnation}`;
60
+ return { header: join(profile, "gentle-agents", "presence", `${stem}.header.json`),
61
+ activity: join(profile, "gentle-agents", "presence", `${stem}.activity.json`) };
62
+ }
63
+
64
+ function header(profile: string) {
65
+ const result = listPresence(profile);
66
+ assert.equal(result.unavailable, undefined);
67
+ assert.equal(result.entries.length, 1);
68
+ return result.entries[0];
69
+ }
70
+
71
+ if (process.env.PRESENCE_TEST_CHILD !== "1") {
72
+ test("projection whitelists summaries and all retained text, excluding invocation payloads", () => {
73
+ const input = rows();
74
+ const projected = projectActivity(input);
75
+ const json = JSON.stringify(projected);
76
+ assert.doesNotMatch(json, /PRIVATE_PROMPT|private\/|CAPABILITY|sessionPath|cancel|args/);
77
+ assert.equal(projected.tasks[0].thread.items.length, 4);
78
+ assert.equal(projected.tasks[0].thread.dropped, 2);
79
+ input[0].thread.items[0].text = "mutated";
80
+ assert.equal(projected.tasks[0].thread.items[0].text, "full retained output");
81
+ });
82
+
83
+ test("headers are private, lightweight, sanitized and recent at the inclusive TTL boundary", (t) => {
84
+ t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"], now: 100_000 });
85
+ const profile = fixture(t);
86
+ const pub = PresencePublisher.start({ profile, sessionId: "/private/session", label: "\x1b[31mWork\nspace", activity: rows() });
87
+ t.after(() => pub.dispose());
88
+ const h = header(profile);
89
+ assert.equal(h.label, "Work space");
90
+ assert.equal(h.sessionHash, createHash("sha256").update("/private/session").digest("hex"));
91
+ assert.equal(h.counts.running, 1);
92
+ assert.doesNotMatch(fs.readFileSync(paths(profile, h).header, "utf8"), /private\/|full retained/);
93
+ assert.equal(listPresence(profile, 115_000).entries[0].recent, true);
94
+ assert.equal(listPresence(profile, 115_001).entries[0].recent, false);
95
+ assert.equal(listPresence(profile, 99_999).entries[0].recent, false);
96
+ if (process.platform !== "win32") {
97
+ assert.equal(fs.statSync(join(profile, "gentle-agents", "presence")).mode & 0o777, 0o700);
98
+ assert.equal(fs.statSync(paths(profile, h).header).mode & 0o777, 0o600);
99
+ }
100
+ });
101
+
102
+ test("long display labels remain schema-valid when clipping lands on whitespace or Unicode", (t) => {
103
+ const profile = fixture(t);
104
+ const pub = PresencePublisher.start({ profile, sessionId: "s", label: `${"a".repeat(119)} tail`, activity: [] });
105
+ assert.equal(header(profile).label, "a".repeat(119));
106
+ pub.dispose();
107
+ const unicode = PresencePublisher.start({ profile, sessionId: "s", label: "🟢".repeat(121), activity: [] });
108
+ t.after(() => unicode.dispose());
109
+ assert.equal(header(profile).label, "🟢".repeat(120));
110
+ });
111
+
112
+ test("updates coalesce, snapshot caller data, and heartbeat never serializes or replaces activity", (t) => {
113
+ t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"], now: 100_000 });
114
+ const profile = fixture(t);
115
+ const pub = publisher(t, profile);
116
+ const first = header(profile);
117
+ const path = paths(profile, first).activity;
118
+ const original = fs.readFileSync(path, "utf8");
119
+ const inode = fs.statSync(path).ino;
120
+ pub.update(rows());
121
+ const stringify = JSON.stringify;
122
+ const serialization = t.mock.method(JSON, "stringify", (value: any) => {
123
+ assert.equal(value?.activity ?? value?.tasks, undefined, "heartbeat must not serialize activity");
124
+ return stringify(value);
125
+ });
126
+ t.mock.timers.tick(5000);
127
+ serialization.mock.restore();
128
+ assert.equal(header(profile).heartbeat, 105_000);
129
+ assert.equal(header(profile).generation, first.generation);
130
+ assert.equal(fs.statSync(path).ino, inode);
131
+ assert.equal(fs.readFileSync(path, "utf8"), original);
132
+ pub.update(rows("older"));
133
+ const latest = rows("latest");
134
+ pub.update(latest);
135
+ latest[0].thread.items[0].text = "caller mutation";
136
+ t.mock.timers.tick(399);
137
+ assert.equal(header(profile).generation, first.generation);
138
+ t.mock.timers.tick(1);
139
+ const next = header(profile);
140
+ assert.equal(next.generation, first.generation + 1);
141
+ assert.equal(readActivity(profile, next).activity?.tasks[0].thread.items[0].text, "latest");
142
+ assert.equal(readActivity(profile, first).unavailable, "generation-mismatch");
143
+ pub.update(rows("transient"));
144
+ pub.update(rows("latest"));
145
+ t.mock.timers.tick(400);
146
+ assert.equal(header(profile).generation, next.generation, "reverting a pending change publishes nothing");
147
+ pub.update(rows("discarded"));
148
+ pub.dispose();
149
+ t.mock.timers.tick(10_000);
150
+ assert.deepEqual(listPresence(profile).entries, []);
151
+ assert.throws(() => pub.update(rows()), /disposed/);
152
+ });
153
+
154
+ test("full under-limit payload is admitted; oversize is explicit and never truncated", (t) => {
155
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
156
+ const profile = fixture(t);
157
+ const text = "x".repeat(ACTIVITY_LIMIT - 2048);
158
+ const pub = publisher(t, profile, rows(text));
159
+ assert.equal(readActivity(profile, header(profile)).activity?.tasks[0].thread.items[0].text, text);
160
+ pub.update(rows("x".repeat(ACTIVITY_LIMIT)));
161
+ t.mock.timers.tick(400);
162
+ assert.equal(header(profile).unavailable, "activity-too-large");
163
+ assert.equal(readActivity(profile, header(profile)).unavailable, "activity-too-large");
164
+ pub.update(rows("recovered"));
165
+ t.mock.timers.tick(400);
166
+ assert.equal(readActivity(profile, header(profile)).activity?.tasks[0].thread.items[0].text, "recovered");
167
+ });
168
+
169
+ test("reader rejects malformed, oversized, wrong digest/schema and unsafe exact targets", (t) => {
170
+ const profile = fixture(t);
171
+ publisher(t, profile);
172
+ const h = header(profile);
173
+ const p = paths(profile, h);
174
+ const original = fs.readFileSync(p.activity);
175
+ fs.writeFileSync(p.activity, "{}");
176
+ assert.equal(readActivity(profile, h).unavailable, "digest-mismatch");
177
+ const malformed = Buffer.from('{"schema":1,"generation":1,"activity":{"tasks":[{}]}}');
178
+ fs.writeFileSync(p.activity, malformed);
179
+ assert.equal(readActivity(profile, { ...h, digest: createHash("sha256").update(malformed).digest("hex") }).unavailable, "malformed");
180
+ fs.writeFileSync(p.activity, Buffer.alloc(ACTIVITY_LIMIT + 1));
181
+ assert.equal(readActivity(profile, h).unavailable, "oversized");
182
+ assert.equal(readActivity(profile, { ...h, incarnation: "../../escape" }).unavailable, "malformed");
183
+ fs.writeFileSync(p.activity, original);
184
+ fs.writeFileSync(p.header, "{");
185
+ assert.equal(listPresence(profile).rejected, 1);
186
+ fs.writeFileSync(p.header, " ".repeat(16 * 1024 + 1));
187
+ assert.equal(listPresence(profile).rejected, 1);
188
+ const { recent: _recent, ...storedHeader } = h;
189
+ fs.writeFileSync(p.header, JSON.stringify({ ...storedHeader, sessionPath: "/private" }));
190
+ assert.equal(listPresence(profile).rejected, 1);
191
+ fs.writeFileSync(p.header, JSON.stringify({ ...storedHeader, sessionHash: "0".repeat(64) }));
192
+ assert.equal(listPresence(profile).rejected, 1, "header identity must match its basename");
193
+ const invalid = JSON.parse(original.toString("utf8"));
194
+ invalid.activity.tasks[0].thread.items[0].sessionPath = "/private";
195
+ const injected = JSON.stringify(invalid);
196
+ fs.writeFileSync(p.activity, injected);
197
+ assert.equal(readActivity(profile, { ...h, digest: createHash("sha256").update(injected).digest("hex") }).unavailable, "malformed");
198
+ });
199
+
200
+ test("bounded directory scan reports overflow and ignores partial/temp files", (t) => {
201
+ const profile = fixture(t);
202
+ publisher(t, profile);
203
+ const root = join(profile, "gentle-agents", "presence");
204
+ fs.writeFileSync(join(root, ".partial.tmp"), "{");
205
+ assert.equal(listPresence(profile).rejected, 0);
206
+ for (let i = 0; i < 130; i++) fs.writeFileSync(join(root, `junk-${i}`), "{}");
207
+ const result = listPresence(profile);
208
+ assert.equal(result.scanned, 128);
209
+ assert.equal(result.overflow, true);
210
+ });
211
+
212
+ test("symlinks, hardlinks, directories and non-private managed roots fail closed", { skip: process.platform === "win32" }, (t) => {
213
+ const profile = fixture(t);
214
+ const pub = publisher(t, profile);
215
+ const h = header(profile);
216
+ const p = paths(profile, h);
217
+ const outside = join(profile, "outside");
218
+ fs.writeFileSync(outside, "untouched");
219
+ fs.unlinkSync(p.activity);
220
+ fs.symlinkSync(outside, p.activity);
221
+ assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
222
+ fs.unlinkSync(p.activity);
223
+ fs.linkSync(outside, p.activity);
224
+ assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
225
+ pub.dispose();
226
+ assert.equal(fs.readFileSync(outside, "utf8"), "untouched");
227
+ assert.ok(fs.existsSync(p.activity), "cleanup refuses a replaced unsafe file");
228
+ fs.unlinkSync(p.activity);
229
+ fs.symlinkSync(outside, p.header);
230
+ assert.equal(listPresence(profile).rejected, 1);
231
+ fs.unlinkSync(p.header);
232
+ fs.linkSync(outside, p.header);
233
+ assert.equal(listPresence(profile).rejected, 1);
234
+ fs.unlinkSync(p.header);
235
+ fs.mkdirSync(p.activity);
236
+ assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
237
+ fs.rmdirSync(p.activity);
238
+ fs.chmodSync(join(profile, "gentle-agents", "presence"), 0o755);
239
+ assert.equal(listPresence(profile).unavailable, "unsafe-directory");
240
+ fs.rmdirSync(join(profile, "gentle-agents", "presence"));
241
+ fs.symlinkSync(profile, join(profile, "gentle-agents", "presence"));
242
+ assert.equal(listPresence(profile).unavailable, "unsafe-directory");
243
+ assert.throws(() => publisher(t, profile), /unsafe-directory/);
244
+ fs.unlinkSync(join(profile, "gentle-agents", "presence"));
245
+ fs.chmodSync(join(profile, "gentle-agents"), 0o777);
246
+ assert.equal(listPresence(profile).unavailable, "unsafe-directory", "shared root cannot be writable by other users");
247
+ fs.chmodSync(join(profile, "gentle-agents"), 0o700);
248
+ fs.rmdirSync(join(profile, "gentle-agents"));
249
+ fs.symlinkSync(profile, join(profile, "gentle-agents"));
250
+ assert.throws(() => publisher(t, profile), /unsafe-directory/, "shared root cannot be a symlink");
251
+ });
252
+
253
+ test("publication failure stops timers without overwriting linked files or another activation", { skip: process.platform === "win32" }, (t) => {
254
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
255
+ const profile = fixture(t);
256
+ const pub = publisher(t, profile);
257
+ const selected = header(profile);
258
+ const sibling = publisher(t, profile);
259
+ const target = paths(profile, selected).activity;
260
+ const outside = join(profile, "replacement");
261
+ fs.writeFileSync(outside, "untouched");
262
+ fs.unlinkSync(target);
263
+ fs.linkSync(outside, target);
264
+ pub.update(rows("must not publish"));
265
+ t.mock.timers.tick(400);
266
+ assert.equal(pub.error, "unsafe-file");
267
+ t.mock.timers.tick(10_000);
268
+ assert.equal(fs.readFileSync(outside, "utf8"), "untouched");
269
+ assert.deepEqual(listPresence(profile).entries.map((h) => h.incarnation), [sibling.target.incarnation]);
270
+ assert.equal(readActivity(profile, selected).unavailable, "unsafe-file");
271
+ });
272
+
273
+ test("history created under umask 022 coexists with a dedicated private presence namespace", async (t) => {
274
+ const profile = fixture(t);
275
+ const store = new TaskStore();
276
+ const task = realTask();
277
+ store.add(task);
278
+ const mask = process.umask(0o022);
279
+ try { await saveTask(historyDir(profile, profile), task, store.thread(task.id)); }
280
+ finally { process.umask(mask); }
281
+ const shared = join(profile, "gentle-agents");
282
+ const before = fs.statSync(shared).mode;
283
+ if (process.platform !== "win32") assert.equal(before & 0o777, 0o755);
284
+ const pub = publisher(t, profile);
285
+ assert.equal(fs.statSync(shared).mode, before, "must not chmod the shared history directory");
286
+ const privateRoot = join(shared, "presence");
287
+ assert.ok(fs.statSync(privateRoot).isDirectory());
288
+ if (process.platform !== "win32") assert.equal(fs.statSync(privateRoot).mode & 0o777, 0o700);
289
+ pub.dispose();
290
+ assert.ok(fs.existsSync(join(historyDir(profile, profile), `${task.id}.json`)));
291
+ });
292
+
293
+ test("real TaskStore projection preserves remote sorting/retention timestamps and header identity", (t) => {
294
+ const profile = fixture(t);
295
+ const store = new TaskStore();
296
+ const task = realTask();
297
+ store.add(task);
298
+ store.apply(task.id, { type: "text", text: "retained" }, 2000);
299
+ store.update(task.id, { status: TASK_STATUS.COMPLETED, endedAt: 2500 });
300
+ const activity = store.list(task.parentSessionId).map((task) => ({ task, thread: store.thread(task.id) }));
301
+ const projected = projectActivity(activity);
302
+ assert.throws(() => projectActivity([{ ...activity[0], task: { ...activity[0].task, endedAt: NaN } }]), /malformed-activity/);
303
+ assert.deepEqual(projected.tasks[0].summary, { id: "t", agent: "worker", label: "summary", model: "model",
304
+ status: TASK_STATUS.COMPLETED, createdAt: 1000, startedAt: 1100, endedAt: 2500, lastActivityAt: 2000 });
305
+ const pub = PresencePublisher.start({ profile, sessionId: task.parentSessionId, label: "Work", activity });
306
+ t.after(() => pub.dispose());
307
+ const h = header(profile);
308
+ assert.equal(h.sessionHash, createHash("sha256").update(task.parentSessionId).digest("hex"));
309
+ assert.deepEqual(readActivity(profile, h).activity, projected);
310
+ assert.doesNotMatch(JSON.stringify(projected), /parentSessionId|PRIVATE_PROMPT|sessionPath|cwd|cancel|args/);
311
+ });
312
+
313
+ test("bounded continuation reaches a live peer beyond 128 stale headers without deleting them", (t) => {
314
+ const profile = fixture(t);
315
+ const pub = publisher(t, profile);
316
+ const { recent: _recent, ...base } = header(profile);
317
+ pub.dispose();
318
+ const root = join(profile, "gentle-agents", "presence");
319
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
320
+ for (let i = 0; i < 140; i++) {
321
+ const h = { ...base, incarnation: randomUUID(), heartbeat: 0 };
322
+ fs.writeFileSync(join(root, `${h.sessionHash}.${h.incarnation}.header.json`), JSON.stringify(h));
323
+ }
324
+ // Choose the late peer from actual filesystem order, not lexical/insertion assumptions.
325
+ const fixtureDir = fs.opendirSync(root);
326
+ let late = "";
327
+ try {
328
+ for (let i = 0; i < 141; i++) {
329
+ const entry = fixtureDir.readSync();
330
+ if (!entry) break;
331
+ late = entry.name;
332
+ }
333
+ } finally { fixtureDir.closeSync(); }
334
+ const live = { ...JSON.parse(fs.readFileSync(join(root, late), "utf8")), heartbeat: Date.now() };
335
+ fs.writeFileSync(join(root, late), JSON.stringify(live));
336
+ const cursor = new presence.PresenceCursor(profile);
337
+ t.after(() => cursor.close());
338
+ const first = cursor.next();
339
+ assert.equal(first.scanned, 128);
340
+ assert.equal(first.overflow, true);
341
+ assert.equal(first.entries.some((h) => h.recent), false);
342
+ const second = cursor.next();
343
+ assert.equal(second.scanned, 12);
344
+ assert.equal(second.overflow, false);
345
+ assert.deepEqual(second.entries.filter((h) => h.recent).map((h) => h.incarnation), [live.incarnation]);
346
+ assert.equal(cursor.next().unavailable, "closed");
347
+ assert.equal(fs.readdirSync(root).length, 140, "stale entries are not deleted");
348
+ const abandoned = new presence.PresenceCursor(profile);
349
+ abandoned.close();
350
+ abandoned.close();
351
+ assert.equal(abandoned.next().unavailable, "closed");
352
+ if (process.platform !== "win32") {
353
+ const unsafe = new presence.PresenceCursor(profile);
354
+ fs.chmodSync(root, 0o755);
355
+ assert.equal(unsafe.next().unavailable, "unsafe-directory");
356
+ assert.equal(unsafe.next().unavailable, "closed", "a failed cursor closes its handle");
357
+ }
358
+ });
359
+ }
360
+
361
+ // Execute this same module in isolated Node processes, without an extra fixture file.
362
+ if (process.env.PRESENCE_TEST_CHILD === "1") {
363
+ const pub = PresencePublisher.start({ profile: process.argv[2], sessionId: "shared", label: "Child", activity: rows() });
364
+ process.send?.(pub.target);
365
+ process.on("message", () => { pub.dispose(); process.exit(0); });
366
+ } else {
367
+ test("two real processes with the same session ID stay distinct and clean up only their incarnation", async (t) => {
368
+ const profile = fixture(t);
369
+ const children = [0, 1].map(() => fork(new URL(import.meta.url), [profile], {
370
+ execArgv: ["--experimental-strip-types"], env: { PRESENCE_TEST_CHILD: "1" }, silent: true,
371
+ }));
372
+ t.after(() => { for (const child of children) if (child.exitCode === null) child.kill(); });
373
+ const targets = await Promise.all(children.map((child) => new Promise<any>((resolve, reject) => {
374
+ child.once("message", resolve);
375
+ child.once("error", reject);
376
+ child.once("exit", (code) => reject(new Error(`child exited before readiness: ${code}`)));
377
+ })));
378
+ assert.equal(targets[0].sessionHash, targets[1].sessionHash);
379
+ assert.notEqual(targets[0].incarnation, targets[1].incarnation);
380
+ assert.equal(listPresence(profile).entries.length, 2);
381
+ const survivor = listPresence(profile).entries.find((h) => h.incarnation === targets[1].incarnation)!;
382
+ assert.ok(readActivity(profile, survivor).activity);
383
+ await new Promise<void>((resolve) => { children[0].once("exit", () => resolve()); children[0].send("dispose"); });
384
+ assert.deepEqual(listPresence(profile).entries.map((h) => h.incarnation), [survivor.incarnation]);
385
+ assert.ok(readActivity(profile, survivor).activity);
386
+ await new Promise<void>((resolve) => { children[1].once("exit", () => resolve()); children[1].send("dispose"); });
387
+ assert.deepEqual(fs.readdirSync(join(profile, "gentle-agents", "presence")), []);
388
+ });
389
+ }