@selesai/code 0.8.4 → 0.8.5

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 (96) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +41 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  7. package/dist/extensions/pi-subagents/README.md +2 -0
  8. package/dist/extensions/pi-subagents/docs/configuration.md +46 -2
  9. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  10. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  11. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  12. package/dist/extensions/pi-subagents/package.json +1 -1
  13. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  14. package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
  15. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  16. package/dist/extensions/pi-subagents/src/extension/index.ts +21 -0
  17. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  18. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  19. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  20. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  21. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  22. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  23. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  24. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  25. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  26. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  27. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  28. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +11 -5
  29. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  30. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  31. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  32. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  33. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  34. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  35. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  36. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  37. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  38. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  39. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +614 -187
  40. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  41. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  42. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  43. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  44. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  45. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  46. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  47. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  48. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  49. package/dist/extensions/pi-subagents/src/shared/types.ts +92 -0
  50. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  51. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  52. package/dist/extensions/pi-subagents/src/tui/fleet.ts +225 -12
  53. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  54. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  55. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  56. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  57. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  58. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  59. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  60. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  61. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  62. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  63. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  64. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  65. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  66. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  67. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  68. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  69. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  70. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  71. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  72. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  73. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  74. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  75. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  76. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  77. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  78. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  79. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  80. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  81. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  82. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  83. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  84. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  85. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  86. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  87. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  88. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  89. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  90. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  91. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  92. package/dist/modes/interactive/interactive-mode.js +48 -1
  93. package/docs/quickstart.md +27 -43
  94. package/docs/settings.md +4 -0
  95. package/docs/usage.md +1 -0
  96. package/package.json +2 -1
@@ -55,6 +55,7 @@ describe("below-editor subagent FleetView", () => {
55
55
 
56
56
  it("renders main plus active children below the editor and bounds every line", () => {
57
57
  const state = stateForTest();
58
+ state.activeAsyncCapacity = { used: 2, limit: 4 };
58
59
  const now = Date.now();
59
60
  for (let index = 0; index < 7; index++) {
60
61
  state.foregroundControls.set(`run-${index}`, {
@@ -98,7 +99,7 @@ describe("below-editor subagent FleetView", () => {
98
99
  const component = widgetFactory!(tui, theme);
99
100
  const compactLines = component.render(80);
100
101
  assert.equal(compactLines.length, 1);
101
- assert.ok(compactLines[0]!.includes("7 active agents"));
102
+ assert.ok(compactLines[0]!.includes("7 active agents · Async runs 2/4"));
102
103
  assert.ok(compactLines[0]!.includes("↓ 13.7k tokens"));
103
104
  assert.ok(compactLines[0]!.includes("↓/← to inspect"));
104
105
  assert.ok(visibleWidth(compactLines[0]!) <= 80);
@@ -122,6 +123,31 @@ describe("below-editor subagent FleetView", () => {
122
123
  }
123
124
  });
124
125
 
126
+ it("keeps occupied capacity visible without active child rows", () => {
127
+ const state = stateForTest();
128
+ state.activeAsyncCapacity = { used: 1, limit: 2 };
129
+ let widgetFactory: ((tui: unknown, theme: typeof theme) => { render(width: number): string[] }) | undefined;
130
+ const ctx = {
131
+ hasUI: true,
132
+ ui: {
133
+ setWidget(_key: string, content: typeof widgetFactory | undefined) { if (content) widgetFactory = content; },
134
+ onTerminalInput() { return () => {}; },
135
+ getEditorText() { return ""; },
136
+ requestRender() {},
137
+ notify() {},
138
+ theme,
139
+ },
140
+ } as unknown as ExtensionContext;
141
+ const fleet = new SubagentFleetStatus(state, () => {}, { refreshMs: 60_000 });
142
+ try {
143
+ fleet.setContext(ctx);
144
+ assert.ok(widgetFactory);
145
+ assert.deepEqual(widgetFactory!({ requestRender() {} }, theme).render(80), [" Async runs 1/2 · ↓ 0 tokens · ↓/← to inspect"]);
146
+ } finally {
147
+ fleet.dispose();
148
+ }
149
+ });
150
+
125
151
  it("keeps one queued agent visible in the compact summary", () => {
126
152
  const state = stateForTest();
127
153
  state.asyncJobs.set("run-worker", {
@@ -596,7 +622,7 @@ describe("below-editor subagent FleetView", () => {
596
622
  assert.deepEqual(statusKeys, inspectorKeys);
597
623
  });
598
624
 
599
- it("uses tracked async task descriptions and per-child token totals", () => {
625
+ it("hides tracked async task descriptions and shows per-child token totals", () => {
600
626
  const state = stateForTest();
601
627
  state.asyncJobs.set("async-run", {
602
628
  asyncId: "async-run",
@@ -630,9 +656,9 @@ describe("below-editor subagent FleetView", () => {
630
656
  const component = widgetFactory!(tui, theme);
631
657
  assert.deepEqual(fleet.handleKey("\x1b[B"), { consume: true });
632
658
  const lines = component.render(180);
633
- assert.ok(lines.some((line) => line.includes("reviewer (gpt-5 · thinking medium)") && line.includes("Review only authentication")));
634
- assert.ok(lines.some((line) => line.includes("worker") && line.includes("Implement only billing")));
635
- assert.ok(lines.every((line) => !line.includes("Review the authentication changes")), "per-child descriptions should replace the run-level fallback when present");
659
+ assert.ok(lines.some((line) => line.includes("reviewer (gpt-5 · thinking medium)")));
660
+ assert.ok(lines.some((line) => line.includes("worker")));
661
+ assert.ok(lines.every((line) => !line.includes("Review only authentication") && !line.includes("Implement only billing") && !line.includes("Review the authentication changes")));
636
662
  assert.ok(lines.some((line) => line.includes("↓ 4.2k tokens")));
637
663
  } finally {
638
664
  fleet.dispose();
@@ -7,8 +7,11 @@ import { visibleWidth, type MarkdownTheme } from "@earendil-works/pi-tui";
7
7
  import { collectFleetSnapshot, openSubagentFleet, SubagentFleetComponent } from "../../src/tui/fleet.ts";
8
8
  import { persistForegroundRunHistory, restoreForegroundRunHistory } from "../../src/runs/foreground/foreground-history.ts";
9
9
  import { FLEET_STATUS_WIDGET_KEY } from "../../src/tui/fleet-status.ts";
10
+ import { registerLivePromptAudit, rewritePromptWithGuidance } from "../../src/runs/foreground/prompt-audit.ts";
10
11
  import { getArtifactPaths, getArtifactsDir, getProjectArtifactsDir } from "../../src/shared/artifacts.ts";
11
12
  import type { SubagentState } from "../../src/shared/types.ts";
13
+ import { fauxAssistantMessage } from "@earendil-works/pi-ai/providers/faux";
14
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
12
15
 
13
16
  function stateForTest(): SubagentState {
14
17
  return {
@@ -92,6 +95,35 @@ const markdownTheme: MarkdownTheme = {
92
95
  };
93
96
 
94
97
  describe("native subagent fleet", () => {
98
+ it("rewrites an authored prompt from guidance without persistence", async () => {
99
+ const calls: unknown[] = [];
100
+ const streamFn = (_model: never, context: unknown) => {
101
+ calls.push(context);
102
+ const stream = createAssistantMessageEventStream();
103
+ queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: fauxAssistantMessage("Rewritten task", { stopReason: "stop" }) }));
104
+ return stream;
105
+ };
106
+ const model = { provider: "faux", id: "rewrite", api: "faux", input: ["text"], reasoning: false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 };
107
+ const ctx = {
108
+ model,
109
+ signal: undefined,
110
+ modelRegistry: {
111
+ async getApiKeyAndHeaders() { return { ok: true as const, apiKey: "test" }; },
112
+ getRegisteredProviderConfig() { return { api: "faux", streamSimple: streamFn }; },
113
+ },
114
+ } as never;
115
+ const rewritten = await rewritePromptWithGuidance({
116
+ ctx,
117
+ authoredTask: "Original task",
118
+ runtimeAdditions: "Runtime context",
119
+ finalEffectivePrompt: "Runtime context\n\nOriginal task",
120
+ guidance: "Make it narrower",
121
+ });
122
+ assert.equal(rewritten, "Rewritten task");
123
+ assert.match(JSON.stringify(calls), /Original task/);
124
+ assert.match(JSON.stringify(calls), /Make it narrower/);
125
+ });
126
+
95
127
  it("collects current-session foreground and flattened async children", () => {
96
128
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fleet-collect-"));
97
129
  try {
@@ -136,6 +168,112 @@ describe("native subagent fleet", () => {
136
168
  }
137
169
  });
138
170
 
171
+ it("shows live prompt summaries and opens Prompt Audit with the authored prompt visible", async () => {
172
+ const sentinel = "PROMPT_AUDIT_SENTINEL_1021";
173
+ const secondSentinel = "PROMPT_AUDIT_SECOND_CHILD";
174
+ const state = stateForTest();
175
+ const control = {
176
+ runId: "prompt-run",
177
+ sessionId: "session-current",
178
+ mode: "parallel" as const,
179
+ startedAt: 10,
180
+ updatedAt: 20,
181
+ activeChildren: new Map([
182
+ [0, { index: 0, agent: "worker", startedAt: 10, updatedAt: 20 }],
183
+ [1, { index: 1, agent: "reviewer", startedAt: 11, updatedAt: 21 }],
184
+ ]),
185
+ };
186
+ state.foregroundControls.set(control.runId, control);
187
+ registerLivePromptAudit(control, 0, sentinel, `[Read from: context.md]\n\n${sentinel}\n\n---\nRuntime acceptance`, { rerun: { params: { agent: "worker", task: sentinel } } });
188
+ registerLivePromptAudit(control, 1, secondSentinel, secondSentinel);
189
+ assert.doesNotMatch(JSON.stringify(control), new RegExp(sentinel), "live prompts must not enter serializable Fleet state");
190
+ const clipboardWrites: string[] = [];
191
+ const redoCalls: Array<{ runId: string; index: number; guidance: string }> = [];
192
+ const component = new SubagentFleetComponent(
193
+ { terminal: { rows: 28, columns: 100 }, requestRender() {} } as never,
194
+ theme as never,
195
+ state,
196
+ () => {},
197
+ {
198
+ refreshMs: 60_000,
199
+ markdownTheme,
200
+ copyText: (text) => { clipboardWrites.push(text); },
201
+ actions: {
202
+ async steer() { return { text: "unused" }; },
203
+ stop() { return { text: "unused" }; },
204
+ async redoPrompt(input) {
205
+ redoCalls.push(input);
206
+ return { text: "Prompt redo started redo-run." };
207
+ },
208
+ },
209
+ },
210
+ );
211
+ try {
212
+ const initialRender = component.render(100).join("\n");
213
+ assert.match(initialRender, new RegExp(`Task: ${sentinel}`));
214
+ assert.match(initialRender, /Prompt audit: 2 live · 3 views · p opens/);
215
+ component.handleInput("p");
216
+ assert.doesNotMatch(component.render(100).join("\n"), /Prompt text hidden|Enter reveal|r hide/);
217
+ assert.match(component.render(100).join("\n"), new RegExp(sentinel));
218
+ component.handleInput("2");
219
+ assert.match(component.render(100).join("\n"), /Read from: context\.md/);
220
+ component.handleInput("3");
221
+ assert.match(component.render(100).join("\n"), /Runtime acceptance/);
222
+ component.handleInput("c");
223
+ await new Promise((resolve) => setImmediate(resolve));
224
+ assert.deepEqual(clipboardWrites, [`[Read from: context.md]\n\n${sentinel}\n\n---\nRuntime acceptance`]);
225
+ assert.match(component.render(100).join("\n"), /Copied visible prompt view/);
226
+ component.handleInput("g");
227
+ for (const char of "make narrower") component.handleInput(char);
228
+ component.handleInput("\r");
229
+ await new Promise((resolve) => setImmediate(resolve));
230
+ assert.deepEqual(redoCalls, [{ runId: "prompt-run", index: 0, guidance: "make narrower", control }]);
231
+ assert.match(component.render(100).join("\n"), /Prompt redo started redo-run/);
232
+ component.handleInput("j");
233
+ assert.match(component.render(100).join("\n"), /Selected: 2\/2/);
234
+ assert.match(component.render(100).join("\n"), /Agent: reviewer/);
235
+ assert.match(component.render(100).join("\n"), new RegExp(secondSentinel));
236
+ component.handleInput("k");
237
+ component.handleInput("1");
238
+ assert.match(component.render(100).join("\n"), /Selected: 1\/2/);
239
+ assert.match(component.render(100).join("\n"), /Agent: worker/);
240
+ assert.match(component.render(100).join("\n"), new RegExp(sentinel));
241
+ component.handleInput("\x1b");
242
+ assert.match(component.render(100).join("\n"), /Prompt audit: 2 live · 3 views · p opens/);
243
+ } finally {
244
+ component.dispose();
245
+ }
246
+ });
247
+
248
+ it("rejects Prompt Audit for a child owned by another session", () => {
249
+ const state = stateForTest();
250
+ const control = {
251
+ runId: "other-run",
252
+ sessionId: "session-other",
253
+ mode: "single" as const,
254
+ startedAt: 10,
255
+ updatedAt: 20,
256
+ activeChildren: new Map([[0, { index: 0, agent: "worker", startedAt: 10, updatedAt: 20 }]]),
257
+ };
258
+ state.foregroundControls.set(control.runId, control);
259
+ registerLivePromptAudit(control, 0, "SECRET", "SECRET");
260
+ const component = new SubagentFleetComponent(
261
+ { terminal: { rows: 28, columns: 100 }, requestRender() {} } as never,
262
+ theme as never,
263
+ state,
264
+ () => {},
265
+ { refreshMs: 60_000, markdownTheme },
266
+ );
267
+ try {
268
+ component.handleInput("p");
269
+ const rendered = component.render(100).join("\n");
270
+ assert.match(rendered, /Prompt Audit is available only/);
271
+ assert.doesNotMatch(rendered, /SECRET/);
272
+ } finally {
273
+ component.dispose();
274
+ }
275
+ });
276
+
139
277
  it("keeps tracked active workflows ahead of older failed terminal history", () => {
140
278
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fleet-history-"));
141
279
  try {
@@ -778,7 +916,7 @@ describe("native subagent fleet", () => {
778
916
  assert.ok(lines.some((line) => line.includes("reviewer")));
779
917
  assert.ok(lines.some((line) => line.includes("foreground · live")));
780
918
  assert.ok(lines.some((line) => line.includes("live-model · thinking high")));
781
- assert.ok(lines.some((line) => line.includes("Task") && line.includes("Implement the active task")));
919
+ assert.ok(lines.every((line) => !line.includes("Implement the active task") && !line.includes("Review the active task")));
782
920
  assert.ok(lines.some((line) => line.includes("Conversation") && line.includes("assistant response")));
783
921
  assert.ok(lines.some((line) => line.includes("Worker live result")));
784
922
  } finally {
@@ -9,6 +9,7 @@ import {
9
9
  settleForegroundSchedulingOwner,
10
10
  updateForegroundChild,
11
11
  } from "../../src/runs/foreground/foreground-control.ts";
12
+ import { getLivePromptAudit } from "../../src/runs/foreground/prompt-audit.ts";
12
13
 
13
14
  function progress(index: number, agent: string, tokens: number): AgentProgress {
14
15
  return {
@@ -43,6 +44,8 @@ describe("foreground child control", () => {
43
44
  beginForegroundChild(control, {
44
45
  index: 0,
45
46
  agent: "reviewer",
47
+ authoredTask: "review",
48
+ effectivePrompt: "review",
46
49
  description: "Review correctness",
47
50
  interrupt: () => { firstInterrupts++; return true; },
48
51
  detach: () => { firstDetaches++; return true; },
@@ -50,11 +53,17 @@ describe("foreground child control", () => {
50
53
  beginForegroundChild(control, {
51
54
  index: 1,
52
55
  agent: "reviewer",
56
+ authoredTask: "review next",
57
+ effectivePrompt: "review next",
53
58
  description: "Review quality",
59
+ rerun: { params: { agent: "reviewer", task: "review next" } },
54
60
  interrupt: () => { secondInterrupts++; return true; },
55
61
  });
56
62
 
57
63
  assert.equal(control.activeChildren?.size, 2);
64
+ assert.equal(getLivePromptAudit(control, 1)?.authoredTask, "review next");
65
+ assert.deepEqual(getLivePromptAudit(control, 1)?.rerun?.params, { agent: "reviewer", task: "review next" });
66
+ assert.doesNotMatch(JSON.stringify(control), /review next/);
58
67
  assert.equal(control.currentIndex, 1);
59
68
  updateForegroundChild(control, 0, progress(0, "reviewer", 120));
60
69
  assert.equal(control.currentIndex, 0);
@@ -72,6 +81,7 @@ describe("foreground child control", () => {
72
81
 
73
82
  updateForegroundChild(control, 1, progress(1, "reviewer", 240));
74
83
  finishForegroundChild(control, 1);
84
+ assert.equal(getLivePromptAudit(control, 1), undefined);
75
85
  assert.equal(control.currentIndex, 0);
76
86
  assert.equal(control.tokens, 120);
77
87
  assert.deepEqual([...control.activeChildren!.keys()], [0]);
@@ -52,7 +52,8 @@ describe("resolveIntercomSessionTarget", () => {
52
52
  });
53
53
 
54
54
  it("uses the current pi-intercom runtime id for unnamed fallback when present", () => {
55
- assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-stableabcdef"), "subagent-chat-stableab");
55
+ assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-stableabcdef"), "subagent-chat-stableabcdef");
56
+ assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-abcdefghijklmnopqrst"), "subagent-chat-abcdefghijklmnopqr");
56
57
  });
57
58
  });
58
59
 
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ arbitrateCompletionGuardRescue,
5
+ createTaskMutationArbiter,
6
+ isCompletionGuardFailure,
7
+ maybeRescueCompletionGuardFailure,
8
+ type TaskMutationArbiter,
9
+ } from "../../src/runs/shared/llm-intent-arbiter.ts";
10
+
11
+ const GUARD_ERROR =
12
+ "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes.";
13
+
14
+ function fakeCtx(overrides: { models?: Array<{ provider?: string; id?: string; api?: string }> } = {}) {
15
+ const models = overrides.models ?? [{ provider: "test", id: "model-1", api: "test-api" }];
16
+ return {
17
+ model: models[0],
18
+ modelRegistry: {
19
+ getAvailable: () => models,
20
+ getRegisteredProviderConfig: () => undefined,
21
+ },
22
+ } as never;
23
+ }
24
+
25
+ function stubArbiter(verdict: "read-only" | "implementation" | "unavailable" | "throw"): TaskMutationArbiter {
26
+ return async () => {
27
+ if (verdict === "throw") throw new Error("boom");
28
+ return verdict;
29
+ };
30
+ }
31
+
32
+ describe("arbitrateCompletionGuardRescue", () => {
33
+ it("rescues only a confident read-only verdict", async () => {
34
+ const rescued = await arbitrateCompletionGuardRescue({
35
+ guardTriggered: true,
36
+ task: "t",
37
+ arbiter: stubArbiter("read-only"),
38
+ });
39
+ assert.deepEqual(rescued, { triggered: false, rescued: true });
40
+ for (const verdict of ["implementation", "unavailable"] as const) {
41
+ const kept = await arbitrateCompletionGuardRescue({
42
+ guardTriggered: true,
43
+ task: "t",
44
+ arbiter: stubArbiter(verdict),
45
+ });
46
+ assert.deepEqual(kept, { triggered: true, rescued: false }, verdict);
47
+ }
48
+ });
49
+
50
+ it("keeps the guard verdict without an arbiter or on arbiter failure", async () => {
51
+ assert.deepEqual(
52
+ await arbitrateCompletionGuardRescue({ guardTriggered: true, task: "t" }),
53
+ { triggered: true, rescued: false },
54
+ );
55
+ assert.deepEqual(
56
+ await arbitrateCompletionGuardRescue({ guardTriggered: true, task: "t", arbiter: stubArbiter("throw") }),
57
+ { triggered: true, rescued: false },
58
+ );
59
+ assert.deepEqual(
60
+ await arbitrateCompletionGuardRescue({ guardTriggered: false, task: "t", arbiter: stubArbiter("read-only") }),
61
+ { triggered: false, rescued: false },
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("isCompletionGuardFailure", () => {
67
+ it("recognizes the guard error prefix", () => {
68
+ assert.equal(isCompletionGuardFailure({ error: GUARD_ERROR }), true);
69
+ assert.equal(isCompletionGuardFailure({ error: "Some other failure." }), false);
70
+ assert.equal(isCompletionGuardFailure({}), false);
71
+ });
72
+ });
73
+
74
+ describe("maybeRescueCompletionGuardFailure", () => {
75
+ it("keeps the failure without an arbiter", async () => {
76
+ const result = { exitCode: 1, error: GUARD_ERROR };
77
+ const rescued = await maybeRescueCompletionGuardFailure(result, "task", undefined);
78
+ assert.equal(rescued, false);
79
+ assert.equal(result.exitCode, 1);
80
+ assert.equal(result.error, GUARD_ERROR);
81
+ });
82
+
83
+ it("rescues only a confident read-only verdict", async () => {
84
+ for (const verdict of ["implementation", "unavailable"] as const) {
85
+ const result = { exitCode: 1, error: GUARD_ERROR };
86
+ const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter(verdict));
87
+ assert.equal(rescued, false, verdict);
88
+ assert.equal(result.exitCode, 1);
89
+ assert.equal(result.error, GUARD_ERROR);
90
+ }
91
+ const result = { exitCode: 1, error: GUARD_ERROR };
92
+ const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("read-only"));
93
+ assert.equal(rescued, true);
94
+ assert.equal(result.exitCode, 0);
95
+ assert.equal(result.error, undefined);
96
+ });
97
+
98
+ it("fails closed when the arbiter throws", async () => {
99
+ const result = { exitCode: 1, error: GUARD_ERROR };
100
+ const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("throw"));
101
+ assert.equal(rescued, false);
102
+ assert.equal(result.exitCode, 1);
103
+ assert.equal(result.error, GUARD_ERROR);
104
+ });
105
+
106
+ it("does not touch unrelated failures", async () => {
107
+ const result = { exitCode: 1, error: "Something else broke." };
108
+ const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("read-only"));
109
+ assert.equal(rescued, false);
110
+ assert.equal(result.exitCode, 1);
111
+ });
112
+ });
113
+
114
+ describe("createTaskMutationArbiter", () => {
115
+ it("is disabled by the env opt-out", () => {
116
+ const previous = process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER;
117
+ process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER = "0";
118
+ try {
119
+ assert.equal(createTaskMutationArbiter(fakeCtx()), undefined);
120
+ } finally {
121
+ if (previous === undefined) delete process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER;
122
+ else process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER = previous;
123
+ }
124
+ });
125
+
126
+ it("returns undefined when no model can be resolved", () => {
127
+ assert.equal(createTaskMutationArbiter(fakeCtx({ models: [] })), undefined);
128
+ });
129
+
130
+ it("returns a working arbiter bound to the host model", async () => {
131
+ const arbiter = createTaskMutationArbiter(fakeCtx(), {
132
+ streamFn: async () => {
133
+ throw new Error("not reached in this seam test");
134
+ },
135
+ });
136
+ assert.notEqual(arbiter, undefined);
137
+ // Without a scripted stream the underlying Agent call cannot succeed;
138
+ // the arbiter must degrade to "unavailable" rather than throw.
139
+ const verdict = await arbiter!("task");
140
+ assert.equal(verdict, "unavailable");
141
+ });
142
+
143
+ it("memoizes verdicts per task", async () => {
144
+ let streamCalls = 0;
145
+ const arbiter = createTaskMutationArbiter(fakeCtx(), {
146
+ streamFn: async () => {
147
+ streamCalls += 1;
148
+ throw new Error("fail");
149
+ },
150
+ })!;
151
+ const v1 = await arbiter("t");
152
+ const v2 = await arbiter("t");
153
+ assert.equal(v1, "unavailable");
154
+ assert.equal(v2, "unavailable");
155
+ assert.equal(streamCalls, 1);
156
+ });
157
+
158
+ it("uses the full task as the cache key", async () => {
159
+ let streamCalls = 0;
160
+ const arbiter = createTaskMutationArbiter(fakeCtx(), {
161
+ streamFn: async () => {
162
+ streamCalls += 1;
163
+ throw new Error("fail");
164
+ },
165
+ })!;
166
+ const prefix = "x".repeat(180);
167
+ await arbiter(`${prefix}A`);
168
+ await arbiter(`${prefix}B`);
169
+ assert.equal(streamCalls, 2);
170
+ });
171
+ });
@@ -11,6 +11,7 @@ import {
11
11
  syncMissionFromAsyncCompletion,
12
12
  } from "../../src/missions/lifecycle.ts";
13
13
  import { readMission } from "../../src/missions/store.ts";
14
+ import { PROMPT_REDACTED } from "../../src/shared/utils.ts";
14
15
 
15
16
  function projectFixture() {
16
17
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-mission-lifecycle-"));
@@ -52,7 +53,8 @@ describe("mission launch lifecycle", () => {
52
53
  });
53
54
  assert.ok(binding);
54
55
  const mission = readMission(binding.location, binding.missionId);
55
- assert.equal(mission.objective, "Map the auth flow");
56
+ assert.equal(mission.objective, PROMPT_REDACTED);
57
+ assert.equal(mission.title, PROMPT_REDACTED);
56
58
  assert.equal(mission.status, "active");
57
59
  const result = attachMissionToLaunchResult({
58
60
  binding,
@@ -80,7 +82,7 @@ describe("mission launch lifecycle", () => {
80
82
  config: test.missionConfig,
81
83
  });
82
84
  assert.ok(parallelOnly);
83
- assert.equal(readMission(parallelOnly.location, parallelOnly.missionId).objective, "Review parallel work");
85
+ assert.equal(readMission(parallelOnly.location, parallelOnly.missionId).objective, PROMPT_REDACTED);
84
86
 
85
87
  assert.throws(() => prepareMissionLaunch({
86
88
  params: { missionId: "" },
@@ -107,7 +109,7 @@ describe("mission launch lifecycle", () => {
107
109
  config: { ...test.missionConfig, enabled: false },
108
110
  });
109
111
  assert.ok(explicit);
110
- assert.equal(readMission(explicit.location, explicit.missionId).objective, "Tiny one-off");
112
+ assert.equal(readMission(explicit.location, explicit.missionId).objective, "Explicit mission");
111
113
 
112
114
  const summaryAlias = prepareMissionLaunch({
113
115
  params: { mission: { summary: "Review active backlog", labels: ["review"] }, task: "Review the current diff" },
@@ -117,7 +119,31 @@ describe("mission launch lifecycle", () => {
117
119
  assert.ok(summaryAlias);
118
120
  assert.deepEqual(readMission(summaryAlias.location, summaryAlias.missionId).labels, ["review"]);
119
121
  assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).title, "Review active backlog");
120
- assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).objective, "Review the current diff");
122
+ assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).objective, "Review active backlog");
123
+ } finally {
124
+ fs.rmSync(test.root, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ it("explains when an explicit mission belongs to another worktree", () => {
129
+ const test = projectFixture();
130
+ const worktreeRoot = path.join(test.root, "worktree");
131
+ fs.mkdirSync(worktreeRoot, { recursive: true });
132
+ try {
133
+ const binding = prepareMissionLaunch({
134
+ params: { mission: { title: "Root mission" }, task: "Prepare root work" },
135
+ projectRoot: test.projectRoot,
136
+ config: test.missionConfig,
137
+ });
138
+ assert.ok(binding);
139
+ const missionDir = path.join(worktreeRoot, ".pi-subagents", "missions");
140
+ assert.throws(() => prepareMissionLaunch({
141
+ params: { missionId: binding.missionId, task: "Prepare worktree work" },
142
+ projectRoot: worktreeRoot,
143
+ config: test.missionConfig,
144
+ }), {
145
+ message: `Mission '${binding.missionId}' was not found in mission directory '${missionDir}' for project root '${worktreeRoot}'. If it was created in another worktree, run the request from that worktree.`,
146
+ });
121
147
  } finally {
122
148
  fs.rmSync(test.root, { recursive: true, force: true });
123
149
  }
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
4
  buildModelCandidates,
5
+ formatModelRetryNote,
5
6
  fuzzyResolveModel,
6
7
  isRetryableModelFailure,
8
+ MODEL_RETRY_DELAYS_MS,
7
9
  normalizeModelSegment,
8
10
  resolveEffectiveSubagentModel,
9
11
  resolveModelCandidate,
@@ -74,6 +76,18 @@ describe("model fallback helpers", () => {
74
76
  assert.equal(isRetryableModelFailure("model load failed"), true);
75
77
  assert.equal(isRetryableModelFailure("Stream ended without finish_reason"), true);
76
78
  assert.equal(isRetryableModelFailure("Request timed out."), true);
79
+ assert.equal(isRetryableModelFailure("503 Service Unavailable"), true);
80
+ assert.equal(isRetryableModelFailure("No body returned for response"), true);
81
+ assert.equal(isRetryableModelFailure("no response body"), true);
82
+ assert.equal(isRetryableModelFailure("ECONNRESET"), true);
83
+ });
84
+
85
+ it("ships a bounded same-model retry schedule and note", () => {
86
+ assert.deepEqual(MODEL_RETRY_DELAYS_MS, [500, 1500, 4000]);
87
+ assert.match(
88
+ formatModelRetryNote({ model: "anthropic/claude-sonnet-4", attempt: 1, maxAttempts: 4, delayMs: 500 }),
89
+ /\[model-retry\] anthropic\/claude-sonnet-4.*attempt 1\/4.*500ms/,
90
+ );
77
91
  });
78
92
 
79
93
  it("does not treat ordinary task/tool failures as retryable model failures", () => {
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import test from "node:test";
7
+ import { createOwnedProcessTreeController } from "../../src/runs/background/owned-process-tree.ts";
8
+
9
+ function processIsActive(pid: number): boolean {
10
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
11
+ return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
12
+ }
13
+
14
+ test("owned process tree fails closed when process-group ownership is unsupported", { skip: process.platform !== "win32" }, async () => {
15
+ const proof = await createOwnedProcessTreeController(999_999).terminate();
16
+ assert.deepEqual(proof, { state: "unknown", reason: "unsupported-platform" });
17
+ });
18
+
19
+ test("owned process tree kills descendants and verifies a TERM-resistant POSIX group", { skip: process.platform === "win32" }, async () => {
20
+ const writer = spawn(process.execPath, ["-e", `
21
+ const { spawn } = require("node:child_process");
22
+ const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
23
+ process.on("SIGTERM", () => {});
24
+ process.stdout.write(String(child.pid) + "\\n");
25
+ setInterval(() => {}, 1000);
26
+ `], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
27
+ assert.ok(writer.pid);
28
+ const grandchildPid = await new Promise<number>((resolve, reject) => {
29
+ writer.once("error", reject);
30
+ writer.stdout!.once("data", (chunk) => resolve(Number(String(chunk).trim())));
31
+ });
32
+ const proof = await createOwnedProcessTreeController(writer.pid, { termGraceMs: 50, killVerifyMs: 1000 }).terminate();
33
+ assert.equal(proof.state, "observed", JSON.stringify(proof));
34
+ assert.equal(processIsActive(writer.pid), false);
35
+ assert.equal(processIsActive(grandchildPid), false);
36
+
37
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-tree-ps-"));
38
+ const realPs = spawnSync("sh", ["-c", "command -v ps"], { encoding: "utf-8" }).stdout.trim();
39
+ const marker = path.join(fixtureDir, "failed-once");
40
+ const originalPath = process.env.PATH;
41
+ try {
42
+ fs.writeFileSync(path.join(fixtureDir, "ps"), `#!/bin/sh\nif [ ! -e "$PI_TEST_PS_MARKER" ]; then\n : > "$PI_TEST_PS_MARKER"\n exit 1\nfi\nexec "$PI_TEST_REAL_PS" "$@"\n`);
43
+ fs.chmodSync(path.join(fixtureDir, "ps"), 0o755);
44
+ process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`;
45
+ process.env.PI_TEST_PS_MARKER = marker;
46
+ process.env.PI_TEST_REAL_PS = realPs;
47
+ const enumerationFailureWriter = spawn(process.execPath, ["-e", `
48
+ process.on("SIGTERM", () => {});
49
+ process.stdout.write("ready\\n");
50
+ setInterval(() => {}, 1000);
51
+ `], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
52
+ assert.ok(enumerationFailureWriter.pid);
53
+ await new Promise<void>((resolve, reject) => {
54
+ enumerationFailureWriter.once("error", reject);
55
+ enumerationFailureWriter.stdout!.once("data", () => resolve());
56
+ });
57
+ const termGraceMs = 50;
58
+ const startedAt = Date.now();
59
+ const failedProof = await createOwnedProcessTreeController(enumerationFailureWriter.pid, { termGraceMs, killVerifyMs: 1000 }).terminate();
60
+ assert.ok(Date.now() - startedAt >= termGraceMs, "enumeration failure still waits through the TERM grace before SIGKILL");
61
+ assert.equal(failedProof.state, "observed", JSON.stringify(failedProof));
62
+ assert.equal(processIsActive(enumerationFailureWriter.pid), false);
63
+ } finally {
64
+ process.env.PATH = originalPath;
65
+ delete process.env.PI_TEST_PS_MARKER;
66
+ delete process.env.PI_TEST_REAL_PS;
67
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
68
+ }
69
+ });