gentle-pi 2.5.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -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
+ }