@dungle-scrubs/tallow 0.8.26 → 0.8.27

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 (48) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/config.js +1 -1
  3. package/dist/interactive-mode-patch.d.ts +1 -0
  4. package/dist/interactive-mode-patch.d.ts.map +1 -1
  5. package/dist/interactive-mode-patch.js +40 -1
  6. package/dist/interactive-mode-patch.js.map +1 -1
  7. package/dist/pid-manager.d.ts +2 -9
  8. package/dist/pid-manager.d.ts.map +1 -1
  9. package/dist/pid-manager.js +1 -58
  10. package/dist/pid-manager.js.map +1 -1
  11. package/dist/pid-schema.d.ts +51 -0
  12. package/dist/pid-schema.d.ts.map +1 -0
  13. package/dist/pid-schema.js +70 -0
  14. package/dist/pid-schema.js.map +1 -0
  15. package/dist/sdk.js +4 -8
  16. package/dist/sdk.js.map +1 -1
  17. package/extensions/__integration__/audit-findings.test.ts +309 -0
  18. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  19. package/extensions/_shared/lazy-init.ts +88 -3
  20. package/extensions/_shared/pid-registry.ts +8 -82
  21. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  22. package/extensions/clear/__tests__/clear.test.ts +38 -0
  23. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  24. package/extensions/mcp-adapter-tool/index.ts +1 -1
  25. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  26. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  27. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  28. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  29. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  30. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
  31. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  32. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  33. package/extensions/subagent-tool/formatting.ts +2 -0
  34. package/extensions/subagent-tool/index.ts +156 -95
  35. package/extensions/subagent-tool/process.ts +126 -32
  36. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  37. package/extensions/tasks/extension.json +1 -0
  38. package/extensions/tasks/index.ts +2 -12
  39. package/extensions/tasks/state/index.ts +26 -0
  40. package/extensions/teams-tool/dashboard.ts +13 -1
  41. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  42. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  43. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  44. package/extensions/wezterm-notify/index.ts +5 -3
  45. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  46. package/package.json +3 -2
  47. package/runtime/pid-schema.ts +13 -0
  48. package/skills/tallow-expert/SKILL.md +1 -1
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tests for the permissions extension registration and wiring.
3
+ *
4
+ * Uses the ExtensionHarness to avoid mock.module() — which contaminates
5
+ * other test files in the same Bun worker. Tests cover command/handler
6
+ * registration and event handler presence without mocking the _shared modules.
7
+ */
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
11
+ import registerPermissions from "../index.js";
12
+
13
+ // ── Helpers ───────────────────────────────────────────────────────────────────
14
+
15
+ type EventName = "session_start" | "tool_call";
16
+ type EventHandler = (event: unknown, ctx: ExtensionContext) => Promise<unknown>;
17
+ type CommandHandler = (args: string | undefined, ctx: unknown) => Promise<void>;
18
+
19
+ interface CapturedPi {
20
+ handlers: Partial<Record<EventName, EventHandler>>;
21
+ commands: Record<string, { description: string; handler: CommandHandler }>;
22
+ }
23
+
24
+ /**
25
+ * Run the extension against a spy pi to capture what it registers.
26
+ *
27
+ * @returns Captured event handlers and commands
28
+ */
29
+ function captureRegistrations(): CapturedPi {
30
+ const handlers: Partial<Record<EventName, EventHandler>> = {};
31
+ const commands: Record<string, { description: string; handler: CommandHandler }> = {};
32
+ const pi = {
33
+ on: (event: string, handler: EventHandler) => {
34
+ handlers[event as EventName] = handler;
35
+ },
36
+ registerCommand: (name: string, opts: { description: string; handler: CommandHandler }) => {
37
+ commands[name] = opts;
38
+ },
39
+ } as unknown as ExtensionAPI;
40
+
41
+ registerPermissions(pi);
42
+ return { handlers, commands };
43
+ }
44
+
45
+ // ════════════════════════════════════════════════════════════════
46
+ // Registration
47
+ // ════════════════════════════════════════════════════════════════
48
+
49
+ describe("permissions extension registration", () => {
50
+ test("registers session_start handler", () => {
51
+ const { handlers } = captureRegistrations();
52
+ expect(handlers.session_start).toBeDefined();
53
+ });
54
+
55
+ test("registers tool_call handler", () => {
56
+ const { handlers } = captureRegistrations();
57
+ expect(handlers.tool_call).toBeDefined();
58
+ });
59
+
60
+ test("registers /permissions command", () => {
61
+ const { commands } = captureRegistrations();
62
+ expect(commands.permissions).toBeDefined();
63
+ expect(commands.permissions.description).toBeTruthy();
64
+ });
65
+ });
66
+
67
+ // ════════════════════════════════════════════════════════════════
68
+ // tool_call handler wiring
69
+ // ════════════════════════════════════════════════════════════════
70
+
71
+ describe("tool_call handler", () => {
72
+ test("skips bash tool (handled by shell-policy)", async () => {
73
+ const { handlers } = captureRegistrations();
74
+ const result = await handlers.tool_call!(
75
+ { type: "tool_call", toolName: "bash", toolCallId: "t1", input: { command: "ls" } },
76
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
77
+ );
78
+ // No block/error — should be undefined (pass-through)
79
+ expect(result).toBeUndefined();
80
+ });
81
+
82
+ test("skips bg_bash tool (handled by shell-policy)", async () => {
83
+ const { handlers } = captureRegistrations();
84
+ const result = await handlers.tool_call!(
85
+ { type: "tool_call", toolName: "bg_bash", toolCallId: "t2", input: { command: "ls" } },
86
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
87
+ );
88
+ expect(result).toBeUndefined();
89
+ });
90
+
91
+ test("skips when no rules configured", async () => {
92
+ const { handlers } = captureRegistrations();
93
+ // Session-start hasn't been called yet, so currentCwd is "", and
94
+ // getPermissions("") returns empty rules → skip
95
+ const result = await handlers.tool_call!(
96
+ {
97
+ type: "tool_call",
98
+ toolName: "read",
99
+ toolCallId: "t3",
100
+ input: { path: "/etc/passwd" },
101
+ },
102
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
103
+ );
104
+ expect(result).toBeUndefined();
105
+ });
106
+ });
107
+
108
+ // ════════════════════════════════════════════════════════════════
109
+ // /permissions command wiring
110
+ // ════════════════════════════════════════════════════════════════
111
+
112
+ describe("/permissions command", () => {
113
+ test("reload subcommand calls reloadPermissions", async () => {
114
+ const { commands } = captureRegistrations();
115
+ const notifications: Array<{ msg: string; type: string }> = [];
116
+ const ctx = {
117
+ cwd: "/tmp",
118
+ ui: {
119
+ notify: (msg: string, type: string) => {
120
+ notifications.push({ msg, type });
121
+ },
122
+ },
123
+ };
124
+
125
+ await commands.permissions.handler("reload", ctx);
126
+ // Should have notified about reload
127
+ expect(notifications.length).toBeGreaterThan(0);
128
+ expect(notifications[0].msg).toContain("Reloaded");
129
+ });
130
+
131
+ test("no args shows rules (or 'no rules' when none configured)", async () => {
132
+ const { commands } = captureRegistrations();
133
+ const notifications: Array<{ msg: string; type: string }> = [];
134
+ const ctx = {
135
+ cwd: "/tmp",
136
+ ui: {
137
+ notify: (msg: string, type: string) => {
138
+ notifications.push({ msg, type });
139
+ },
140
+ },
141
+ };
142
+
143
+ await commands.permissions.handler("", ctx);
144
+ expect(notifications.length).toBeGreaterThan(0);
145
+ // Should show either "No permission rules" or "Active Permission Rules"
146
+ const msg = notifications[0].msg;
147
+ expect(msg.includes("No permission rules") || msg.includes("Permission Rules")).toBe(true);
148
+ });
149
+
150
+ test("test subcommand evaluates Tool(specifier) format", async () => {
151
+ const { commands } = captureRegistrations();
152
+ const notifications: Array<{ msg: string; type: string }> = [];
153
+ const ctx = {
154
+ cwd: "/tmp",
155
+ ui: {
156
+ notify: (msg: string, type: string) => {
157
+ notifications.push({ msg, type });
158
+ },
159
+ },
160
+ };
161
+
162
+ await commands.permissions.handler("test Bash(ls)", ctx);
163
+ expect(notifications.length).toBeGreaterThan(0);
164
+ // Should contain action verdict info
165
+ expect(notifications[0].msg).toContain("Action:");
166
+ });
167
+
168
+ test("test subcommand handles bare tool name", async () => {
169
+ const { commands } = captureRegistrations();
170
+ const notifications: Array<{ msg: string; type: string }> = [];
171
+ const ctx = {
172
+ cwd: "/tmp",
173
+ ui: {
174
+ notify: (msg: string, type: string) => {
175
+ notifications.push({ msg, type });
176
+ },
177
+ },
178
+ };
179
+
180
+ await commands.permissions.handler("test read", ctx);
181
+ expect(notifications.length).toBeGreaterThan(0);
182
+ expect(notifications[0].msg).toContain("Action:");
183
+ });
184
+ });
185
+
186
+ // ════════════════════════════════════════════════════════════════
187
+ // ExtensionHarness integration
188
+ // ════════════════════════════════════════════════════════════════
189
+
190
+ describe("permissions via ExtensionHarness", () => {
191
+ test("tool_call event handler is registered", async () => {
192
+ const harness = ExtensionHarness.create();
193
+ await harness.loadExtension(registerPermissions);
194
+ // Verify the extension registered a tool_call handler by firing one
195
+ const results = await harness.fireEvent("tool_call", {
196
+ type: "tool_call",
197
+ toolName: "read",
198
+ toolCallId: "h-1",
199
+ input: { path: "foo.txt" },
200
+ });
201
+ // No rules configured → should not block
202
+ const blocked = results.some(
203
+ (r) => r && typeof r === "object" && (r as { block?: boolean }).block
204
+ );
205
+ expect(blocked).toBe(false);
206
+ });
207
+
208
+ test("/permissions command is registered", async () => {
209
+ const harness = ExtensionHarness.create();
210
+ await harness.loadExtension(registerPermissions);
211
+ expect(harness.commands.has("permissions")).toBe(true);
212
+ });
213
+ });
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import progressIndicator from "../index.js";
4
+
5
+ describe("progress-indicator extension", () => {
6
+ let originalIsTTY: boolean;
7
+ let written: string[];
8
+ let originalWrite: typeof process.stdout.write;
9
+
10
+ beforeEach(() => {
11
+ originalIsTTY = process.stdout.isTTY;
12
+ originalWrite = process.stdout.write;
13
+ written = [];
14
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
15
+ process.stdout.write = ((chunk: string) => {
16
+ written.push(chunk);
17
+ return true;
18
+ }) as typeof process.stdout.write;
19
+ });
20
+
21
+ afterEach(() => {
22
+ Object.defineProperty(process.stdout, "isTTY", {
23
+ value: originalIsTTY,
24
+ configurable: true,
25
+ });
26
+ process.stdout.write = originalWrite;
27
+ });
28
+
29
+ test("registers turn_start, turn_end, agent_end, session_shutdown handlers", () => {
30
+ const events: string[] = [];
31
+ const pi = {
32
+ on: (event: string) => {
33
+ events.push(event);
34
+ },
35
+ } as unknown as ExtensionAPI;
36
+
37
+ progressIndicator(pi);
38
+ expect(events).toContain("turn_start");
39
+ expect(events).toContain("turn_end");
40
+ expect(events).toContain("agent_end");
41
+ expect(events).toContain("session_shutdown");
42
+ });
43
+
44
+ test("turn_start writes indeterminate OSC sequence", () => {
45
+ const handlers: Record<string, () => void> = {};
46
+ const pi = {
47
+ on: (event: string, handler: () => void) => {
48
+ handlers[event] = handler;
49
+ },
50
+ } as unknown as ExtensionAPI;
51
+
52
+ progressIndicator(pi);
53
+ handlers.turn_start();
54
+
55
+ expect(written).toHaveLength(1);
56
+ expect(written[0]).toContain("9;4;3");
57
+ });
58
+
59
+ test("turn_end writes clear OSC sequence", () => {
60
+ const handlers: Record<string, () => void> = {};
61
+ const pi = {
62
+ on: (event: string, handler: () => void) => {
63
+ handlers[event] = handler;
64
+ },
65
+ } as unknown as ExtensionAPI;
66
+
67
+ progressIndicator(pi);
68
+ handlers.turn_end();
69
+
70
+ expect(written).toHaveLength(1);
71
+ expect(written[0]).toContain("9;4;0");
72
+ });
73
+
74
+ test("agent_end writes clear OSC sequence", () => {
75
+ const handlers: Record<string, () => void> = {};
76
+ const pi = {
77
+ on: (event: string, handler: () => void) => {
78
+ handlers[event] = handler;
79
+ },
80
+ } as unknown as ExtensionAPI;
81
+
82
+ progressIndicator(pi);
83
+ handlers.agent_end();
84
+
85
+ expect(written).toHaveLength(1);
86
+ expect(written[0]).toContain("9;4;0");
87
+ });
88
+
89
+ test("skips write when stdout is not a TTY", () => {
90
+ Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
91
+
92
+ const handlers: Record<string, () => void> = {};
93
+ const pi = {
94
+ on: (event: string, handler: () => void) => {
95
+ handlers[event] = handler;
96
+ },
97
+ } as unknown as ExtensionAPI;
98
+
99
+ progressIndicator(pi);
100
+ handlers.turn_start();
101
+
102
+ expect(written).toHaveLength(0);
103
+ });
104
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import randomSpinner from "../index.js";
4
+
5
+ describe("random-spinner extension", () => {
6
+ test("registers session_start handler", () => {
7
+ const events: string[] = [];
8
+ const pi = {
9
+ on: (event: string) => {
10
+ events.push(event);
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ randomSpinner(pi);
15
+ expect(events).toContain("session_start");
16
+ });
17
+
18
+ test("does not register any commands or tools", () => {
19
+ const commands: string[] = [];
20
+ const tools: string[] = [];
21
+ const pi = {
22
+ on: () => {},
23
+ registerCommand: (name: string) => {
24
+ commands.push(name);
25
+ },
26
+ registerTool: (opts: { name: string }) => {
27
+ tools.push(opts.name);
28
+ },
29
+ } as unknown as ExtensionAPI;
30
+
31
+ randomSpinner(pi);
32
+ expect(commands).toHaveLength(0);
33
+ expect(tools).toHaveLength(0);
34
+ });
35
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import showPrompt from "../index.js";
4
+
5
+ describe("show-system-prompt extension", () => {
6
+ test("registers show-system-prompt command", () => {
7
+ const commands: string[] = [];
8
+ const pi = {
9
+ registerCommand: (name: string) => {
10
+ commands.push(name);
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ showPrompt(pi);
15
+ expect(commands).toContain("show-system-prompt");
16
+ });
17
+
18
+ test("handler logs system prompt and notifies", async () => {
19
+ let handler: ((args: string, ctx: unknown) => Promise<void>) | undefined;
20
+ const pi = {
21
+ registerCommand: (
22
+ _name: string,
23
+ opts: { handler: (args: string, ctx: unknown) => Promise<void> }
24
+ ) => {
25
+ handler = opts.handler;
26
+ },
27
+ } as unknown as ExtensionAPI;
28
+
29
+ showPrompt(pi);
30
+
31
+ const notify = mock(() => {});
32
+ const ctx = {
33
+ getSystemPrompt: () => "You are a test prompt",
34
+ ui: { notify },
35
+ };
36
+
37
+ const origLog = console.log;
38
+ const logged: string[] = [];
39
+ console.log = (...args: unknown[]) => {
40
+ logged.push(args.join(" "));
41
+ };
42
+ try {
43
+ await handler!("", ctx);
44
+ } finally {
45
+ console.log = origLog;
46
+ }
47
+
48
+ expect(logged.some((l) => l.includes("You are a test prompt"))).toBe(true);
49
+ expect(notify).toHaveBeenCalledWith("System prompt logged to terminal", "info");
50
+ });
51
+ });
@@ -133,7 +133,7 @@ describe("subagent presentation rendering", () => {
133
133
  expect(rendered).toContain("<dim>Implement authentication flow with retry handling</dim>");
134
134
  });
135
135
 
136
- it("keeps long parallel call previews informative without dumping the full tail", () => {
136
+ it("renders parallel call header without inlining task text", () => {
137
137
  const component = tool.renderCall?.(
138
138
  {
139
139
  tasks: [
@@ -152,7 +152,8 @@ describe("subagent presentation rendering", () => {
152
152
 
153
153
  const rendered = renderComponent(component);
154
154
  expect(rendered).toContain("<accent>parallel (1 tasks)</accent>");
155
- expect(rendered).toContain("KEEP_THIS_SEGMENT_VISIBLE");
155
+ // Parallel renderCall shows header + metadata only — no inline task preview
156
+ expect(rendered).not.toContain("KEEP_THIS_SEGMENT_VISIBLE");
156
157
  expect(rendered).not.toContain("END_MARKER_SHOULD_TRUNCATE");
157
158
  });
158
159
 
@@ -260,8 +261,8 @@ describe("subagent presentation rendering", () => {
260
261
  if (!component) throw new Error("subagent.renderResult returned undefined");
261
262
 
262
263
  const rendered = renderComponent(component);
263
- expect(rendered).toContain("<b><toolTitle>subagent</toolTitle></b>");
264
- expect(rendered).toContain("<accent>parallel</accent>");
264
+ // Result no longer repeats the "subagent parallel" header (call header already shows it)
265
+ expect(rendered).not.toContain("<toolTitle>subagent</toolTitle>");
265
266
  expect(rendered).toContain("├─");
266
267
  expect(rendered).toContain("└─");
267
268
  expect(rendered).toContain("<dim>(gpt-5.1)</dim>");
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import type { SingleResult } from "../formatting.js";
3
3
  import {
4
4
  applyStalledClassification,
5
+ createStalledSubagentErrorMessage,
5
6
  createWatchdogHeartbeatState,
6
7
  evaluateWatchdogStatus,
7
8
  type ForegroundWatchdogThresholds,
@@ -13,6 +14,7 @@ import {
13
14
  SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV,
14
15
  SUBAGENT_STARTUP_TIMEOUT_MS_ENV,
15
16
  SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV,
17
+ SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV,
16
18
  terminateProcessWithGrace,
17
19
  } from "../process.js";
18
20
 
@@ -21,6 +23,7 @@ const TEST_THRESHOLDS: ForegroundWatchdogThresholds = {
21
23
  killGraceMs: 50,
22
24
  startupTimeoutMs: 1_000,
23
25
  toolExecutionTimeoutMs: 8_000,
26
+ wallClockTimeoutMs: 20_000,
24
27
  };
25
28
 
26
29
  interface ManualTimer {
@@ -124,6 +127,52 @@ describe("foreground subagent liveness watchdog", () => {
124
127
  expect(state.activeToolCalls).toBe(0);
125
128
  });
126
129
 
130
+ it("terminates active agents that exceed wall-clock timeout", () => {
131
+ let state = createWatchdogHeartbeatState(0);
132
+ // Agent is actively producing heartbeats — not stalled by liveness checks
133
+ state = recordWatchdogHeartbeat(state, 5_000);
134
+ state = recordWatchdogHeartbeat(state, 10_000);
135
+ state = recordWatchdogHeartbeat(state, 15_000);
136
+ state = recordWatchdogHeartbeat(state, 19_000);
137
+ expect(evaluateWatchdogStatus(state, 19_500, TEST_THRESHOLDS).kind).toBe("healthy");
138
+
139
+ // Wall-clock timeout (20s) fires even though last heartbeat was recent
140
+ const status = evaluateWatchdogStatus(state, 20_100, TEST_THRESHOLDS);
141
+ expect(status.kind).toBe("stalled");
142
+ if (status.kind !== "stalled") return;
143
+ expect(status.phase).toBe("wall_clock");
144
+ });
145
+
146
+ it("wall-clock timeout takes precedence over other stall phases", () => {
147
+ // Agent started and never sent a heartbeat — would normally be startup stall
148
+ const state = createWatchdogHeartbeatState(0);
149
+ // But wall-clock fires first when both thresholds are exceeded
150
+ const status = evaluateWatchdogStatus(state, 25_000, TEST_THRESHOLDS);
151
+ expect(status.kind).toBe("stalled");
152
+ if (status.kind !== "stalled") return;
153
+ expect(status.phase).toBe("wall_clock");
154
+ });
155
+
156
+ it("wall-clock error message differs from liveness stall messages", () => {
157
+ const wallClockMsg = createStalledSubagentErrorMessage({
158
+ elapsedMs: 900_000,
159
+ kind: "stalled",
160
+ phase: "wall_clock",
161
+ timeoutMs: 900_000,
162
+ });
163
+ expect(wallClockMsg).toContain("wall-clock timeout");
164
+ expect(wallClockMsg).toContain("TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS");
165
+ expect(wallClockMsg).not.toContain("slow provider startup");
166
+
167
+ const startupMsg = createStalledSubagentErrorMessage({
168
+ elapsedMs: 60_000,
169
+ kind: "stalled",
170
+ phase: "startup",
171
+ timeoutMs: 60_000,
172
+ });
173
+ expect(startupMsg).not.toContain("wall-clock");
174
+ });
175
+
127
176
  it("treats message updates and tool execution events as heartbeats", () => {
128
177
  expect(isWatchdogHeartbeatEventType("message_update")).toBe(true);
129
178
  expect(isWatchdogHeartbeatEventType("tool_execution_start")).toBe(true);
@@ -136,10 +185,12 @@ describe("foreground subagent liveness watchdog", () => {
136
185
  [SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV]: "7000",
137
186
  [SUBAGENT_STARTUP_TIMEOUT_MS_ENV]: "3000",
138
187
  [SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]: "11000",
188
+ [SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV]: "1800000",
139
189
  });
140
190
  expect(thresholds.inactivityTimeoutMs).toBe(7_000);
141
191
  expect(thresholds.startupTimeoutMs).toBe(3_000);
142
192
  expect(thresholds.toolExecutionTimeoutMs).toBe(11_000);
193
+ expect(thresholds.wallClockTimeoutMs).toBe(1_800_000);
143
194
  });
144
195
 
145
196
  it("stalled termination escalates and resolves without hanging", async () => {
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildSubprocessArgs, type SubprocessArgsOptions } from "../process.js";
3
+
4
+ describe("buildSubprocessArgs", () => {
5
+ /** Minimal options — no session, no model, just a task. */
6
+ const minimal: SubprocessArgsOptions = { task: "do something" };
7
+
8
+ it("always places -p as the last flag, right before the task text", () => {
9
+ const args = buildSubprocessArgs(minimal);
10
+ const pIdx = args.lastIndexOf("-p");
11
+ expect(pIdx).toBeGreaterThanOrEqual(0);
12
+ expect(pIdx).toBe(args.length - 2);
13
+ expect(args[pIdx + 1]).toBe("Task: do something");
14
+ });
15
+
16
+ it("uses --no-session when session is omitted", () => {
17
+ const args = buildSubprocessArgs(minimal);
18
+ expect(args).toContain("--no-session");
19
+ expect(args).not.toContain("--session");
20
+ });
21
+
22
+ it("uses --session <id> when session is provided", () => {
23
+ const args = buildSubprocessArgs({ ...minimal, session: "my-session" });
24
+ expect(args).toContain("--session");
25
+ expect(args[args.indexOf("--session") + 1]).toBe("my-session");
26
+ expect(args).not.toContain("--no-session");
27
+ });
28
+
29
+ it("--no-session is never consumed as -p value (regression: 86a8d26e)", () => {
30
+ // The original bug: -p was placed before --no-session, so Commander
31
+ // treated "--no-session" as the prompt text and the real task became
32
+ // a stray positional argument → "too many arguments".
33
+ const args = buildSubprocessArgs(minimal);
34
+ const pIdx = args.indexOf("-p");
35
+ const noSessionIdx = args.indexOf("--no-session");
36
+ // -p must come AFTER --no-session in the array
37
+ expect(pIdx).toBeGreaterThan(noSessionIdx);
38
+ });
39
+
40
+ it("includes --model when modelDisplayName is provided", () => {
41
+ const args = buildSubprocessArgs({
42
+ ...minimal,
43
+ modelDisplayName: "anthropic/claude-sonnet-4-6",
44
+ });
45
+ const mIdx = args.indexOf("--model");
46
+ expect(mIdx).toBeGreaterThanOrEqual(0);
47
+ expect(args[mIdx + 1]).toBe("anthropic/claude-sonnet-4-6");
48
+ // Still before -p
49
+ expect(mIdx).toBeLessThan(args.lastIndexOf("-p"));
50
+ });
51
+
52
+ it("includes --tools when tools are provided", () => {
53
+ const args = buildSubprocessArgs({ ...minimal, tools: ["read", "bash", "edit"] });
54
+ const tIdx = args.indexOf("--tools");
55
+ expect(tIdx).toBeGreaterThanOrEqual(0);
56
+ expect(args[tIdx + 1]).toBe("read,bash,edit");
57
+ expect(tIdx).toBeLessThan(args.lastIndexOf("-p"));
58
+ });
59
+
60
+ it("includes --skill for each skill", () => {
61
+ const args = buildSubprocessArgs({ ...minimal, skills: ["tdd", "git"] });
62
+ const firstSkill = args.indexOf("--skill");
63
+ expect(firstSkill).toBeGreaterThanOrEqual(0);
64
+ expect(args[firstSkill + 1]).toBe("tdd");
65
+ const secondSkill = args.indexOf("--skill", firstSkill + 1);
66
+ expect(secondSkill).toBeGreaterThanOrEqual(0);
67
+ expect(args[secondSkill + 1]).toBe("git");
68
+ // Both before -p
69
+ expect(secondSkill).toBeLessThan(args.lastIndexOf("-p"));
70
+ });
71
+
72
+ it("includes --append-system-prompt when path is provided", () => {
73
+ const args = buildSubprocessArgs({
74
+ ...minimal,
75
+ systemPromptPath: "/tmp/prompt.md",
76
+ });
77
+ const sIdx = args.indexOf("--append-system-prompt");
78
+ expect(sIdx).toBeGreaterThanOrEqual(0);
79
+ expect(args[sIdx + 1]).toBe("/tmp/prompt.md");
80
+ expect(sIdx).toBeLessThan(args.lastIndexOf("-p"));
81
+ });
82
+
83
+ it("produces correct full arg array with all options", () => {
84
+ const args = buildSubprocessArgs({
85
+ session: "sess-123",
86
+ modelDisplayName: "openai/gpt-5",
87
+ tools: ["read", "write"],
88
+ skills: ["tdd"],
89
+ systemPromptPath: "/tmp/prompt.md",
90
+ task: "fix the tests",
91
+ });
92
+ expect(args).toEqual([
93
+ "--mode",
94
+ "json",
95
+ "--session",
96
+ "sess-123",
97
+ "--model",
98
+ "openai/gpt-5",
99
+ "--tools",
100
+ "read,write",
101
+ "--skill",
102
+ "tdd",
103
+ "--append-system-prompt",
104
+ "/tmp/prompt.md",
105
+ "-p",
106
+ "Task: fix the tests",
107
+ ]);
108
+ });
109
+
110
+ it("omits optional flags when not provided", () => {
111
+ const args = buildSubprocessArgs({ task: "hello" });
112
+ expect(args).toEqual(["--mode", "json", "--no-session", "-p", "Task: hello"]);
113
+ });
114
+
115
+ it("always starts with --mode json", () => {
116
+ const args = buildSubprocessArgs(minimal);
117
+ expect(args[0]).toBe("--mode");
118
+ expect(args[1]).toBe("json");
119
+ });
120
+ });
@@ -37,6 +37,8 @@ export interface SingleResult {
37
37
  stopReason?: string;
38
38
  errorMessage?: string;
39
39
  step?: number;
40
+ /** Timestamp (ms) when this subagent started executing. */
41
+ startTime?: number;
40
42
  /** Tool names that were denied permission during execution. */
41
43
  deniedTools?: string[];
42
44
  }