gentle-pi 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -3,8 +3,10 @@
3
3
  // hardcoded copies of it survived a pin bump once and reported installing
4
4
  // v2.1.11 while writing v2.2.0 to disk, which is the one moment an operator
5
5
  // most needs the number to be true.
6
+ import { dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { INSTALLER_VERSION, installGentleAi } from "./gentle-ai-installer.mjs";
7
- import { installTuiModeSetting } from "./install-tui-mode-setting.mjs";
9
+ import { installTuiModeSetting, isPiManagedInstall } from "./install-tui-mode-setting.mjs";
8
10
 
9
11
  if (process.env.GENTLE_PI_SKIP_GENTLE_AI_INSTALL === "1") {
10
12
  console.warn("GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1: skipped package-local Gentle AI installation; native review operations will fail with package-local-binary-missing until gentle-pi is reinstalled.");
@@ -20,11 +22,16 @@ if (process.env.GENTLE_PI_SKIP_GENTLE_AI_INSTALL === "1") {
20
22
 
21
23
  // A native failure never changes settings; the explicit native-only skip does.
22
24
  if (!process.exitCode) {
23
- try {
24
- const result = await installTuiModeSetting();
25
- if (result.changed) console.log("gentle-pi enabled fullscreen in global Pi settings; /settings can switch back to regular.");
26
- } catch (error) {
27
- console.error(`gentle-pi could not enable fullscreen: ${error instanceof Error ? error.message : String(error)}`);
28
- process.exitCode = 1;
25
+ const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
26
+ if (!isPiManagedInstall(packageDir)) {
27
+ console.log(`gentle-pi skipped enabling fullscreen in global Pi settings: ${packageDir} is not a pi-managed install (npm install -g, a git checkout, and npx all land here).`);
28
+ } else {
29
+ try {
30
+ const result = await installTuiModeSetting();
31
+ if (result.changed) console.log("gentle-pi enabled fullscreen in global Pi settings; /settings can switch back to regular.");
32
+ } catch (error) {
33
+ console.error(`gentle-pi could not enable fullscreen: ${error instanceof Error ? error.message : String(error)}`);
34
+ process.exitCode = 1;
35
+ }
29
36
  }
30
37
  }
@@ -1,10 +1,44 @@
1
1
  import { closeSync, constants, fchmodSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
 
8
+ // The two directory layouts Pi's own package manager creates when it installs
9
+ // a package into an agent home: the npm-backed `npm/node_modules/<package>`
10
+ // (user scope `<agent dir>/npm/node_modules/gentle-pi`, project scope
11
+ // `.pi/npm/node_modules/gentle-pi`) and the git-backed
12
+ // `git/github.com/Gentleman-Programming/<package>` layout. A path is checked
13
+ // for these sequences anywhere in its segments, not anchored to a specific
14
+ // resolved agent home — unlike installTuiModeSetting's own ownership check.
15
+ const PI_MANAGED_SEGMENT_SEQUENCES = [
16
+ ["npm", "node_modules"],
17
+ ["git", "github.com", "Gentleman-Programming"],
18
+ ];
19
+
20
+ function containsSequence(segments, sequence) {
21
+ for (let start = 0; start + sequence.length <= segments.length; start += 1) {
22
+ if (sequence.every((part, offset) => segments[start + offset] === part)) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /** Gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs), not
28
+ * installTuiModeSetting or installIsolatedTuiModeSetting: true when
29
+ * `packageDir` (the directory of the gentle-pi package actually running,
30
+ * typically derived from that script's own import.meta.url) sits under one of
31
+ * the directory layouts above. A plain `npm install -g gentle-pi`, a
32
+ * development git checkout, an `npx` cache directory, or a pnpm store never
33
+ * match, so the postinstall entry skips writing the user's global Pi settings
34
+ * for those instead of relying solely on installTuiModeSetting's own,
35
+ * differently-scoped ownership check.
36
+ */
37
+ export function isPiManagedInstall(packageDir) {
38
+ const segments = resolve(packageDir).split(sep);
39
+ return PI_MANAGED_SEGMENT_SEQUENCES.some((sequence) => containsSequence(segments, sequence));
40
+ }
41
+
8
42
  function inspect(path) {
9
43
  try { return lstatSync(path); }
10
44
  catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
@@ -112,3 +146,46 @@ export async function installTuiModeSetting(options = {}) {
112
146
  }
113
147
  }
114
148
  }
149
+
150
+ /** Writes tuiMode: "fullscreen" into a directory gentle-shell's own isolated-home
151
+ * bootstrap (T2) just created and owns. Unlike installTuiModeSetting, there is no
152
+ * "physically installed under this home's npm/node_modules" ownership check to
153
+ * satisfy: the caller already knows it created `dir` moments ago as gentle-shell's
154
+ * dedicated agent home, so the only safety property that still matters is the one
155
+ * every writer here needs — atomic, non-symlink, cooperative-lock-respecting.
156
+ */
157
+ export async function installIsolatedTuiModeSetting(dir) {
158
+ const home = realpathSync(resolve(dir));
159
+ assertDirectories([home]);
160
+ const settingsPath = join(home, "settings.json");
161
+ const lockPath = `${settingsPath}.lock`;
162
+ const lock = await acquireLock(lockPath);
163
+ const started = Date.now();
164
+ let staging;
165
+ try {
166
+ assertDirectories([home]);
167
+ const original = readSettings(settingsPath);
168
+ if (original.value.tuiMode === "fullscreen") return { changed: false, recognized: true };
169
+ staging = join(home, `.settings-fullscreen-${randomUUID()}.tmp`);
170
+ const fd = openSync(staging, "wx", original.stat ? original.stat.mode & 0o777 : 0o600);
171
+ try {
172
+ if (original.stat) fchmodSync(fd, original.stat.mode & 0o777);
173
+ writeFileSync(fd, `${JSON.stringify({ ...original.value, tuiMode: "fullscreen" }, null, 2)}\n`, "utf8");
174
+ fsyncSync(fd);
175
+ } finally { closeSync(fd); }
176
+ assertDirectories([home]);
177
+ const latest = readSettings(settingsPath);
178
+ if (latest.text !== original.text || (original.stat ? !sameFile(original.stat, latest.stat) : latest.stat !== undefined)) {
179
+ throw new Error("settings.json changed concurrently; retry installation");
180
+ }
181
+ if (!sameFile(lock, inspect(lockPath)) || Date.now() - started >= 5000) throw new Error("Fullscreen settings lock ownership expired; retry installation");
182
+ renameSync(staging, settingsPath);
183
+ staging = undefined;
184
+ return { changed: true, recognized: true };
185
+ } finally {
186
+ if (realpathSync(home) === home) {
187
+ if (staging) unlinkSync(staging);
188
+ if (sameFile(lock, inspect(lockPath))) rmdirSync(lockPath);
189
+ }
190
+ }
191
+ }
@@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  const root = join(fileURLToPath(new URL("..", import.meta.url)));
9
9
 
10
10
  const requiredPaths = [
11
+ "bin/gentle-shell.mjs",
11
12
  "assets/orchestrator.md",
12
13
  "assets/orchestrator-delegation.md",
13
14
  "assets/orchestrator-memory.md",
@@ -54,6 +55,7 @@ const requiredPaths = [
54
55
  "extensions/sdd-init.ts",
55
56
  "extensions/skill-registry.ts",
56
57
  "lib/gentle-ai-binary.ts",
58
+ "lib/gentle-shell-launcher.ts",
57
59
  "lib/native-review-cli.ts",
58
60
  "lib/provider-contract-bundle.ts",
59
61
  "lib/review-host-relay.ts",
@@ -62,6 +64,7 @@ const requiredPaths = [
62
64
  "lib/sdd-preflight.ts",
63
65
  "lib/telemetry-trigger.ts",
64
66
  "runtime/gentle-ai-binary.mjs",
67
+ "runtime/gentle-shell-launcher.mjs",
65
68
  "runtime/native-review-cli.mjs",
66
69
  "runtime/review-integration-v2.mjs",
67
70
  "runtime/review-risk-assessment.mjs",
@@ -70,6 +73,7 @@ const requiredPaths = [
70
73
  "scripts/check-provider-contract.mjs",
71
74
  "scripts/gentle-ai-installer.mjs",
72
75
  "scripts/install-gentle-ai.mjs",
76
+ "scripts/install-tui-mode-setting.mjs",
73
77
  "scripts/mirror-provider-contract.mjs",
74
78
  "tests/fixtures/native-review-cli/v2.1.3/start.json",
75
79
  "tests/fixtures/provider-contract-bundle/v1.1.0/README.md",
@@ -105,7 +109,6 @@ const requiredPaths = [
105
109
  "skills/issue-creation/SKILL.md",
106
110
  "skills/judgment-day/SKILL.md",
107
111
  "skills/rdd-defect-workflow/SKILL.md",
108
- "skills/release/SKILL.md",
109
112
  "skills/skill-creator/SKILL.md",
110
113
  "skills/skill-improver/SKILL.md",
111
114
  "skills/skill-registry/SKILL.md",
@@ -339,7 +342,7 @@ async function main() {
339
342
  });
340
343
 
341
344
  if (driftedContracts.length > 0) {
342
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.4.0 runtime's vendored Gentle AI contract artifacts:");
345
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.5.0 runtime's vendored Gentle AI contract artifacts:");
343
346
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
344
347
  process.exit(1);
345
348
  }
@@ -384,7 +387,7 @@ async function main() {
384
387
  process.exit(1);
385
388
  }
386
389
 
387
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.4.0 runtime).`);
390
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.5.0 runtime).`);
388
391
  }
389
392
 
390
393
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -0,0 +1,407 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord, type TaskStatus } from "../lib/agents-protocol.ts";
4
+ import {
5
+ ACTIVITY_SCHEMA,
6
+ ACTIVITY_WIDGET_KEY,
7
+ createRpcActivityPublisher,
8
+ encodeActivityLines,
9
+ projectRpcActivity,
10
+ type RpcActivity,
11
+ type RpcTask,
12
+ } from "../lib/agents-rpc-publisher.ts";
13
+
14
+ // gentle-agents RPC publisher: a pure projection of TaskStore state into the
15
+ // bounded JSON payload an interactive RPC host receives through setWidget,
16
+ // plus the store-driven, coalesced publisher that pushes it.
17
+
18
+ function task(id: string, parentSessionId: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
19
+ return {
20
+ id,
21
+ agent: "worker",
22
+ mode: "task",
23
+ prompt: "p",
24
+ label: "p",
25
+ cwd: "/r",
26
+ parentSessionId,
27
+ status: TASK_STATUS.RUNNING,
28
+ createdAt: 1000,
29
+ startedAt: 1000,
30
+ endedAt: null,
31
+ model: "gpt",
32
+ thinking: undefined,
33
+ sessionPath: "/sessions/child.jsonl",
34
+ error: null,
35
+ result: null,
36
+ lastStep: "working",
37
+ lastActivityAt: 1000,
38
+ turns: 0,
39
+ toolCalls: 0,
40
+ tokens: 0,
41
+ cost: 0,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ /** Build one already-projected `RpcTask` fixture directly, bypassing `projectRpcActivity`, so `encodeActivityLines` shrink-stage tests control exact sizes. */
47
+ function rpcTask(id: string, status: TaskStatus, endedAt: number | null, opts: { items?: number; itemTextLen?: number; labelLen?: number } = {}): RpcTask {
48
+ const { items = 1, itemTextLen = 20, labelLen = 4 } = opts;
49
+ return {
50
+ summary: {
51
+ id,
52
+ agent: "a",
53
+ label: "l".repeat(labelLen),
54
+ prompt: "p",
55
+ status,
56
+ createdAt: 1,
57
+ startedAt: 1,
58
+ endedAt,
59
+ lastStep: "s",
60
+ lastActivityAt: 1,
61
+ turns: 0,
62
+ toolCalls: 0,
63
+ error: null,
64
+ },
65
+ thread: {
66
+ version: 1,
67
+ dropped: 0,
68
+ items: Array.from({ length: items }, (_v, index) => ({ kind: "note" as const, text: `${"n".repeat(itemTextLen)}${index}` })),
69
+ },
70
+ };
71
+ }
72
+
73
+ function byteLength(value: string): number {
74
+ return Buffer.byteLength(value, "utf8");
75
+ }
76
+
77
+ test("projectRpcActivity whitelists task fields and orders running, waiting, queued, then finished by endedAt desc", () => {
78
+ const store = new TaskStore();
79
+ store.add(task("running-1", "s1", { status: TASK_STATUS.RUNNING, agent: "explore", label: "Explore X", prompt: "short prompt", turns: 2, toolCalls: 1, createdAt: 1, lastActivityAt: 5 }));
80
+ store.add(task("waiting-1", "s1", { status: TASK_STATUS.WAITING, createdAt: 2, lastActivityAt: 6 }));
81
+ store.add(task("queued-1", "s1", { status: TASK_STATUS.QUEUED, createdAt: 3, startedAt: null, lastActivityAt: 3 }));
82
+ store.add(task("finished-old", "s1", { status: TASK_STATUS.COMPLETED, createdAt: 4, endedAt: 100, lastActivityAt: 100 }));
83
+ store.add(task("finished-new", "s1", { status: TASK_STATUS.FAILED, createdAt: 5, endedAt: 200, lastActivityAt: 200, error: "boom" }));
84
+
85
+ const activity = projectRpcActivity(store);
86
+
87
+ assert.equal(activity.schema, ACTIVITY_SCHEMA);
88
+ assert.deepEqual(activity.summary, store.summary());
89
+ assert.deepEqual(activity.tasks.map((entry) => entry.summary.id), ["running-1", "waiting-1", "queued-1", "finished-new", "finished-old"]);
90
+
91
+ const runningSummary = activity.tasks[0]!.summary;
92
+ assert.deepEqual(
93
+ Object.keys(runningSummary).sort(),
94
+ ["id", "agent", "label", "prompt", "status", "createdAt", "startedAt", "endedAt", "lastStep", "lastActivityAt", "turns", "toolCalls", "error"].sort(),
95
+ "only the whitelisted fields are projected: cwd, parentSessionId, mode, model, thinking, sessionPath, result, tokens, and cost never leak",
96
+ );
97
+ assert.equal(runningSummary.agent, "explore");
98
+ assert.equal(runningSummary.label, "Explore X");
99
+ assert.equal(runningSummary.prompt, "short prompt");
100
+ assert.equal(activity.tasks[4]!.summary.error, null);
101
+ assert.equal(activity.tasks[3]!.summary.error, "boom");
102
+ });
103
+
104
+ test("projectRpcActivity truncates the prompt, tool args, and tool output to their bounds", () => {
105
+ const store = new TaskStore();
106
+ store.add(task("t1", "s1", { prompt: "x".repeat(250) }));
107
+ store.apply("t1", { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { note: "y".repeat(600) } }, 1);
108
+ store.apply("t1", { type: TASK_EVENT.TOOL_END, callId: "c1", output: "z".repeat(600), isError: false }, 2);
109
+
110
+ const activity = projectRpcActivity(store);
111
+ const summary = activity.tasks[0]!.summary;
112
+ assert.equal(summary.prompt.length, 200);
113
+ assert.ok(summary.prompt.endsWith("…"));
114
+
115
+ const toolItem = activity.tasks[0]!.thread.items.at(-1) as { kind: string; name: string; args: string; running: boolean; isError: boolean; output: string };
116
+ assert.equal(toolItem.kind, "tool");
117
+ assert.equal(toolItem.name, "bash");
118
+ assert.equal(toolItem.running, false);
119
+ assert.equal(toolItem.isError, false);
120
+ assert.ok(toolItem.args.length <= 500 && toolItem.args.endsWith("…"));
121
+ assert.ok(toolItem.output.length <= 500 && toolItem.output.endsWith("…"));
122
+ assert.match(toolItem.args, /^\{"note":"y+…$/);
123
+ });
124
+
125
+ test("projectRpcActivity keeps only the last N thread items per task (default 40)", () => {
126
+ const store = new TaskStore();
127
+ store.add(task("t1", "s1"));
128
+ for (let index = 0; index < 50; index += 1) store.apply("t1", { type: TASK_EVENT.NOTE, text: `note-${index}` }, index);
129
+
130
+ const activity = projectRpcActivity(store);
131
+ const items = activity.tasks[0]!.thread.items as { text: string }[];
132
+ assert.equal(items.length, 40);
133
+ assert.equal(items[0]!.text, "note-10");
134
+ assert.equal(items.at(-1)!.text, "note-49");
135
+ });
136
+
137
+ test("projectRpcActivity truncates text, thinking, and note item text to 2000 characters", () => {
138
+ const store = new TaskStore();
139
+ store.add(task("t1", "s1"));
140
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "a".repeat(3000) }, 1);
141
+ store.apply("t1", { type: TASK_EVENT.THINKING, text: "b".repeat(3000) }, 2);
142
+ store.apply("t1", { type: TASK_EVENT.NOTE, text: "c".repeat(3000) }, 3);
143
+
144
+ const activity = projectRpcActivity(store);
145
+ const items = activity.tasks[0]!.thread.items as { kind: string; text: string }[];
146
+
147
+ assert.equal(items.length, 3);
148
+ for (const item of items) {
149
+ assert.ok(item.text.length <= 2000, `${item.kind} item must be bounded to 2000 characters`);
150
+ assert.ok(item.text.endsWith("…"), `${item.kind} item must carry the truncation marker`);
151
+ }
152
+ });
153
+
154
+ test("projectRpcActivity truncates a synthetic 1 MiB text item to 2000 characters", () => {
155
+ const store = new TaskStore();
156
+ store.add(task("t1", "s1"));
157
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "x".repeat(1024 * 1024) }, 1);
158
+
159
+ const activity = projectRpcActivity(store);
160
+ const item = activity.tasks[0]!.thread.items[0] as { text: string };
161
+
162
+ assert.equal(item.text.length, 2000);
163
+ assert.ok(item.text.endsWith("…"));
164
+ });
165
+
166
+ test("projectRpcActivity truncates the summary error, label, and lastStep fields to 500 characters", () => {
167
+ const store = new TaskStore();
168
+ store.add(task("t1", "s1", { label: "l".repeat(600) }));
169
+ store.apply("t1", { type: TASK_EVENT.AGENT_END, text: "", outcome: "error", diagnostic: "e".repeat(600) }, 1);
170
+
171
+ const activity = projectRpcActivity(store);
172
+ const summary = activity.tasks[0]!.summary;
173
+
174
+ assert.ok(summary.label.length <= 500 && summary.label.endsWith("…"));
175
+ assert.ok(summary.lastStep.length <= 500 && summary.lastStep.endsWith("…"));
176
+ assert.ok(summary.error !== null && summary.error.length <= 500 && summary.error.endsWith("…"));
177
+ });
178
+
179
+ test("projectRpcActivity honors a custom maxThreadItems override", () => {
180
+ const store = new TaskStore();
181
+ store.add(task("t1", "s1"));
182
+ for (let index = 0; index < 5; index += 1) store.apply("t1", { type: TASK_EVENT.NOTE, text: `n${index}` }, index);
183
+
184
+ const activity = projectRpcActivity(store, { maxThreadItems: 2 });
185
+ assert.deepEqual((activity.tasks[0]!.thread.items as { text: string }[]).map((item) => item.text), ["n3", "n4"]);
186
+ });
187
+
188
+ test("encodeActivityLines returns the activity untouched as a single line when it already fits", () => {
189
+ const activity: RpcActivity = { schema: ACTIVITY_SCHEMA, summary: { running: 0, queued: 0, waiting: 0, finished: 0 }, tasks: [] };
190
+
191
+ const lines = encodeActivityLines(activity);
192
+
193
+ assert.equal(lines.length, 1);
194
+ assert.deepEqual(JSON.parse(lines[0]!), activity);
195
+ });
196
+
197
+ test("encodeActivityLines halves thread items per task when only halving can help", () => {
198
+ const activity: RpcActivity = {
199
+ schema: ACTIVITY_SCHEMA,
200
+ summary: { running: 1, queued: 0, waiting: 0, finished: 0 },
201
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 16, itemTextLen: 50 })],
202
+ };
203
+ const maxBytes = Math.floor(byteLength(JSON.stringify(activity)) / 2);
204
+
205
+ const [line] = encodeActivityLines(activity, maxBytes);
206
+ const shrunk = JSON.parse(line!) as RpcActivity;
207
+
208
+ assert.equal(shrunk.tasks.length, 1);
209
+ assert.ok(shrunk.tasks[0]!.thread.items.length < 16, "items must have been halved down");
210
+ assert.ok(shrunk.tasks[0]!.thread.items.length >= 1, "halving never removes the last item on a running task");
211
+ });
212
+
213
+ test("encodeActivityLines drops a finished task's thread items before dropping the task itself", () => {
214
+ const activity: RpcActivity = {
215
+ schema: ACTIVITY_SCHEMA,
216
+ summary: { running: 1, queued: 0, waiting: 0, finished: 1 },
217
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 10 }), rpcTask("f1", TASK_STATUS.COMPLETED, 100, { items: 1, itemTextLen: 5000 })],
218
+ };
219
+ const full = byteLength(JSON.stringify(activity));
220
+ const maxBytes = full - 4000; // enough slack that only emptying f1's thread is needed
221
+
222
+ const [line] = encodeActivityLines(activity, maxBytes);
223
+ const shrunk = JSON.parse(line!) as RpcActivity;
224
+
225
+ const finishedTask = shrunk.tasks.find((entry) => entry.summary.id === "f1")!;
226
+ assert.deepEqual(finishedTask.thread.items, [], "the finished task's thread is emptied, not the task itself");
227
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "r1"), "the running task is untouched");
228
+ });
229
+
230
+ test("encodeActivityLines drops whole finished tasks, oldest first, once emptying threads is not enough", () => {
231
+ const activity: RpcActivity = {
232
+ schema: ACTIVITY_SCHEMA,
233
+ summary: { running: 1, queued: 0, waiting: 0, finished: 2 },
234
+ tasks: [
235
+ rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 10 }),
236
+ rpcTask("f-new", TASK_STATUS.COMPLETED, 200, { items: 1, itemTextLen: 10, labelLen: 3000 }),
237
+ rpcTask("f-old", TASK_STATUS.COMPLETED, 100, { items: 1, itemTextLen: 10, labelLen: 3000 }),
238
+ ],
239
+ };
240
+ const emptiedThreads: RpcActivity = { ...activity, tasks: activity.tasks.map((entry) => (entry.summary.status === TASK_STATUS.COMPLETED ? { ...entry, thread: { ...entry.thread, items: [] } } : entry)) };
241
+ const maxBytes = byteLength(JSON.stringify(emptiedThreads)) - 1000; // still too big with both finished threads emptied
242
+
243
+ const [line] = encodeActivityLines(activity, maxBytes);
244
+ const shrunk = JSON.parse(line!) as RpcActivity;
245
+
246
+ assert.ok(!shrunk.tasks.some((entry) => entry.summary.id === "f-old"), "the oldest finished task must be dropped first");
247
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "f-new"), "the more recently finished task survives longer");
248
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "r1"), "the running task must never be dropped");
249
+ });
250
+
251
+ test("encodeActivityLines empties every remaining active task's thread as a last resort, emitting a summary-only payload", () => {
252
+ const activity: RpcActivity = {
253
+ schema: ACTIVITY_SCHEMA,
254
+ summary: { running: 2, queued: 0, waiting: 0, finished: 0 },
255
+ tasks: [
256
+ rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 2000 }),
257
+ rpcTask("r2", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 2000 }),
258
+ ],
259
+ };
260
+ // No finished tasks to drop, and both running tasks are already down to
261
+ // one item each; only emptying every thread can still shrink this.
262
+ const maxBytes = byteLength(JSON.stringify(activity)) - 1000;
263
+
264
+ const [line] = encodeActivityLines(activity, maxBytes);
265
+ const shrunk = JSON.parse(line!) as RpcActivity;
266
+
267
+ assert.equal(shrunk.tasks.length, 2, "no active task's summary is ever dropped");
268
+ for (const entry of shrunk.tasks) assert.deepEqual(entry.thread.items, [], "every task's thread is emptied, summary-only");
269
+ });
270
+
271
+ test("encodeActivityLines never throws even when nothing left can be dropped to fit", () => {
272
+ const activity: RpcActivity = {
273
+ schema: ACTIVITY_SCHEMA,
274
+ summary: { running: 1, queued: 0, waiting: 0, finished: 0 },
275
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 3, itemTextLen: 50 })],
276
+ };
277
+
278
+ const lines = encodeActivityLines(activity, 1);
279
+
280
+ assert.equal(lines.length, 1);
281
+ assert.doesNotThrow(() => JSON.parse(lines[0]!));
282
+ });
283
+
284
+ function fakeScheduler() {
285
+ const pending: Array<{ id: number; fn: () => void }> = [];
286
+ let nextId = 0;
287
+ return {
288
+ schedule: (fn: () => void, _ms: number) => {
289
+ const id = nextId++;
290
+ pending.push({ id, fn });
291
+ return () => {
292
+ const index = pending.findIndex((entry) => entry.id === id);
293
+ if (index !== -1) pending.splice(index, 1);
294
+ };
295
+ },
296
+ pendingCount: () => pending.length,
297
+ flushAll: () => {
298
+ const due = pending.splice(0, pending.length);
299
+ for (const entry of due) entry.fn();
300
+ },
301
+ };
302
+ }
303
+
304
+ test("createRpcActivityPublisher coalesces multiple task changes into one flush per window", () => {
305
+ const store = new TaskStore();
306
+ store.add(task("t1", "s1"));
307
+ const scheduler = fakeScheduler();
308
+ const calls: string[][] = [];
309
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (key, lines) => { assert.equal(key, ACTIVITY_WIDGET_KEY); calls.push(lines); } }, schedule: scheduler.schedule });
310
+
311
+ publisher.start();
312
+ assert.equal(scheduler.pendingCount(), 1, "start schedules exactly one coalescing flush");
313
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "hello" }, 1);
314
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: " world" }, 2);
315
+ assert.equal(scheduler.pendingCount(), 1, "further changes inside the coalescing window do not add timers");
316
+ assert.equal(calls.length, 0, "nothing is published before the coalescing timer fires");
317
+
318
+ scheduler.flushAll();
319
+
320
+ assert.equal(calls.length, 1);
321
+ const activity = JSON.parse(calls[0]![0]!) as RpcActivity;
322
+ assert.equal(activity.schema, ACTIVITY_SCHEMA);
323
+ assert.equal(activity.tasks[0]!.summary.id, "t1");
324
+ });
325
+
326
+ test("createRpcActivityPublisher tracks a task created after start through the summary subscription", () => {
327
+ const store = new TaskStore();
328
+ const scheduler = fakeScheduler();
329
+ const calls: string[][] = [];
330
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
331
+
332
+ publisher.start();
333
+ scheduler.flushAll();
334
+ calls.length = 0;
335
+
336
+ store.add(task("late-task", "s1"));
337
+ assert.equal(scheduler.pendingCount(), 1, "adding a task schedules a coalesced flush");
338
+ scheduler.flushAll();
339
+ calls.length = 0;
340
+
341
+ store.apply("late-task", { type: TASK_EVENT.TEXT, text: "streamed" }, 1);
342
+ assert.equal(scheduler.pendingCount(), 1, "the new task's own thread changes must also be tracked");
343
+ scheduler.flushAll();
344
+
345
+ const activity = JSON.parse(calls[0]![0]!) as RpcActivity;
346
+ assert.deepEqual((activity.tasks[0]!.thread.items as { text: string }[]).map((item) => item.text), ["streamed"]);
347
+ });
348
+
349
+ test("createRpcActivityPublisher stop unsubscribes everything and publishes exactly one final frame", () => {
350
+ const store = new TaskStore();
351
+ store.add(task("t1", "s1"));
352
+ const scheduler = fakeScheduler();
353
+ const calls: string[][] = [];
354
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
355
+ publisher.start();
356
+ scheduler.flushAll();
357
+ calls.length = 0;
358
+
359
+ publisher.stop();
360
+
361
+ assert.equal(calls.length, 1, "stop publishes exactly one final frame");
362
+ assert.equal(scheduler.pendingCount(), 0, "stop cancels any pending coalescing timer");
363
+
364
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "after stop" }, 10);
365
+ assert.equal(scheduler.pendingCount(), 0, "task subscriptions are torn down by stop");
366
+ assert.equal(calls.length, 1, "no further frame is published after stop");
367
+ });
368
+
369
+ test("createRpcActivityPublisher flush publishes immediately and cancels a pending coalescing timer", () => {
370
+ const store = new TaskStore();
371
+ store.add(task("t1", "s1"));
372
+ const scheduler = fakeScheduler();
373
+ const calls: string[][] = [];
374
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
375
+ publisher.start();
376
+ scheduler.flushAll();
377
+ calls.length = 0;
378
+
379
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "pending" }, 1);
380
+ assert.equal(scheduler.pendingCount(), 1);
381
+
382
+ publisher.flush();
383
+
384
+ assert.equal(calls.length, 1);
385
+ assert.equal(scheduler.pendingCount(), 0, "flush cancels the timer it preempted");
386
+ });
387
+
388
+ test("createRpcActivityPublisher swallows setWidget errors through an injectable onError", () => {
389
+ const store = new TaskStore();
390
+ store.add(task("t1", "s1"));
391
+ const scheduler = fakeScheduler();
392
+ const errors: unknown[] = [];
393
+ const publisher = createRpcActivityPublisher({
394
+ store,
395
+ ui: { setWidget: () => { throw new Error("boom"); } },
396
+ schedule: scheduler.schedule,
397
+ onError: (error) => errors.push(error),
398
+ });
399
+
400
+ assert.doesNotThrow(() => {
401
+ publisher.start();
402
+ scheduler.flushAll();
403
+ });
404
+ assert.equal(errors.length, 1);
405
+ assert.match(String((errors[0] as Error).message), /boom/);
406
+ assert.doesNotThrow(() => publisher.stop());
407
+ });
@@ -5,6 +5,7 @@ import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinitio
5
5
  import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
6
6
  import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
7
7
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
8
+ import { INTERACTIVE_HOST_ENV } from "../lib/rpc-host.ts";
8
9
 
9
10
  // Gentle Agents runner: every subagent is a child `pi --mode rpc` process.
10
11
  // The host only parses JSON lines, applies deltas to the store, answers
@@ -806,6 +807,15 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
806
807
  assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
807
808
  });
808
809
 
810
+ test("AgentRunner strips the interactive-host signal from every spawned child env", async () => {
811
+ const { runner, spawnOptions } = harness();
812
+ runner.run(request({ env: { PATH: "/fixture", [INTERACTIVE_HOST_ENV]: "1" } }));
813
+ await tick();
814
+
815
+ assert.equal(spawnOptions[0]?.env[INTERACTIVE_HOST_ENV], undefined, "subagent children never see the interactive-host signal");
816
+ assert.equal(spawnOptions[0]?.env.PATH, "/fixture", "unrelated inherited env is preserved");
817
+ });
818
+
809
819
  function ipcCleanupHarness(connected: boolean | undefined) {
810
820
  const child = fakeChild({ exitOnKill: false });
811
821
  const disconnectListeners: Array<(...args: unknown[]) => void> = [];
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { ANIMATION_SCHEMA, parseAnimationPolicyFile, resolveAnimationPolicy, writeAnimationPolicy } from "../lib/animation-policy.ts";
7
+
8
+ test("animation policy strictly accepts only the v1 schema and three modes", () => {
9
+ for (const policy of ["quality", "performance", "potato"]) {
10
+ assert.equal(parseAnimationPolicyFile(JSON.stringify({ schema: ANIMATION_SCHEMA, policy })), policy);
11
+ }
12
+ for (const raw of ["bad", "null", "[]", '{}', '{"policy":"potato"}', ...[
13
+ { schema: "gentle-pi.animations/v2", policy: "potato" },
14
+ { schema: ANIMATION_SCHEMA, policy: "POTATO" },
15
+ { schema: ANIMATION_SCHEMA, policy: false },
16
+ { schema: ANIMATION_SCHEMA, policy: "quality", extra: true },
17
+ ].map((value) => JSON.stringify(value))]) assert.equal(parseAnimationPolicyFile(raw), undefined);
18
+ });
19
+
20
+ test("global policy defaults to quality, persists atomically, and attributes malformed input without rewriting", () => {
21
+ const home = mkdtempSync(join(tmpdir(), "gp-animations-"));
22
+ try {
23
+ const options = { gentlePiConfigHome: home };
24
+ assert.equal(resolveAnimationPolicy(options).policy, "quality");
25
+ assert.equal(resolveAnimationPolicy(options).source, "default");
26
+ for (const policy of ["potato", "performance", "quality"] as const) {
27
+ const path = writeAnimationPolicy(policy, options);
28
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: ANIMATION_SCHEMA, policy });
29
+ assert.equal(resolveAnimationPolicy(options).policy, policy);
30
+ assert.equal(resolveAnimationPolicy(options).source, "global_file");
31
+ assert.deepEqual(readdirSync(home), ["animations.json"]);
32
+ }
33
+ const path = join(home, "animations.json");
34
+ writeFileSync(path, "broken");
35
+ const result = resolveAnimationPolicy(options);
36
+ assert.equal(result.policy, "quality");
37
+ assert.equal(result.source, "global_file");
38
+ assert.equal(result.malformed, true);
39
+ assert.equal(result.globalFile, path);
40
+ assert.equal(readFileSync(path, "utf8"), "broken");
41
+ } finally { rmSync(home, { recursive: true, force: true }); }
42
+ });