pi-ui-extend 1.0.24 → 1.0.30

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 (39) hide show
  1. package/README.md +5 -1
  2. package/dist/app/app.d.ts +1 -0
  3. package/dist/app/app.js +12 -0
  4. package/dist/app/icons.d.ts +1 -0
  5. package/dist/app/icons.js +2 -0
  6. package/dist/app/input/input-controller.d.ts +2 -0
  7. package/dist/app/input/input-controller.js +8 -5
  8. package/dist/app/rendering/render-controller.js +2 -0
  9. package/dist/app/rendering/status-line-renderer.d.ts +4 -1
  10. package/dist/app/rendering/status-line-renderer.js +12 -0
  11. package/dist/app/screen/mouse-controller.d.ts +4 -1
  12. package/dist/app/screen/mouse-controller.js +9 -0
  13. package/dist/app/session/agent-pause-controller.d.ts +30 -0
  14. package/dist/app/session/agent-pause-controller.js +178 -0
  15. package/dist/app/session/session-event-controller.d.ts +1 -0
  16. package/dist/app/session/session-event-controller.js +22 -15
  17. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  18. package/dist/app/session/session-lifecycle-controller.js +1 -0
  19. package/dist/app/types.d.ts +10 -0
  20. package/dist/config.js +1 -1
  21. package/dist/default-pix-config.js +2 -2
  22. package/external/pi-tools-suite/README.md +1 -1
  23. package/external/pi-tools-suite/docs/browser-qa-subagent.md +34 -2
  24. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +24 -18
  25. package/external/pi-tools-suite/src/async-subagents/core/config.ts +14 -3
  26. package/external/pi-tools-suite/src/async-subagents/core/process.ts +19 -0
  27. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +97 -17
  28. package/external/pi-tools-suite/src/async-subagents/core/stop.ts +4 -2
  29. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +150 -19
  30. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +78 -0
  31. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +80 -0
  32. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-flow.example.jsonc +54 -1
  33. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +1260 -79
  34. package/external/pi-tools-suite/src/dcp/index.ts +21 -0
  35. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +92 -0
  36. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +21 -16
  37. package/external/pi-tools-suite/src/todo/index.ts +86 -46
  38. package/external/pi-tools-suite/src/todo/todo.ts +1 -1
  39. package/package.json +1 -1
@@ -284,6 +284,7 @@ export type StatusLineLayout = {
284
284
  compactToolsWidget?: StatusCompactToolsWidgetLayout;
285
285
  terminalBellSoundWidget?: StatusTerminalBellSoundWidgetLayout;
286
286
  promptEnhancerWidget?: StatusPromptEnhancerWidgetLayout;
287
+ agentPauseWidget?: StatusAgentPauseWidgetLayout;
287
288
  voiceWidget?: StatusVoiceWidgetLayout;
288
289
  };
289
290
  export type StatusQuickScrollWidgetLayout = {
@@ -318,6 +319,10 @@ export type StatusPromptEnhancerWidgetLayout = {
318
319
  startColumn: number;
319
320
  endColumn: number;
320
321
  };
322
+ export type StatusAgentPauseWidgetLayout = {
323
+ startColumn: number;
324
+ endColumn: number;
325
+ };
321
326
  export type StatusTerminalBellSoundTarget = {
322
327
  row: number;
323
328
  startColumn: number;
@@ -589,6 +594,11 @@ export type StatusPromptEnhancerTarget = {
589
594
  startColumn: number;
590
595
  endColumn: number;
591
596
  };
597
+ export type StatusAgentPauseTarget = {
598
+ row: number;
599
+ startColumn: number;
600
+ endColumn: number;
601
+ };
592
602
  export type StatusUserJumpTarget = {
593
603
  row: number;
594
604
  startColumn: number;
package/dist/config.js CHANGED
@@ -55,7 +55,7 @@ const DEFAULT_OUTPUT_FILTERS = {
55
55
  };
56
56
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
57
57
  const DEFAULT_PROMPT_ENHANCER = {
58
- modelRef: "zai/glm-5-turbo",
58
+ modelRef: "openai-codex/gpt-5.6-luna",
59
59
  };
60
60
  const DEFAULT_AUTOCOMPLETE = {
61
61
  modelRef: "zai/glm-5-turbo",
@@ -52,9 +52,9 @@ export const DEFAULT_PIX_CONFIG_JSONC = String.raw `{
52
52
  "antigravity/antigravity-claude-*": "error"
53
53
  },
54
54
 
55
- "promptEnhancer": { "modelRef": "zai/glm-5-turbo" },
55
+ "promptEnhancer": { "modelRef": "openai-codex/gpt-5.6-luna" },
56
56
  "autocomplete": { "modelRef": "zai/glm-5-turbo", "debounceMs": 350, "timeoutMs": 3000, "maxTokens": 48, "maxPromptTokens": 1200, "includeRecentMessages": 0 },
57
- "sessionTitle": { "modelRef": "zai/glm-5-turbo", "fallbackModels": ["openai-codex/gpt-5.3-codex-spark"] },
57
+ "sessionTitle": { "modelRef": "openai-codex/gpt-5.6-luna", "fallbackModels": ["zai/glm-5-turbo"] },
58
58
  "dictation": {
59
59
  "language": "en",
60
60
  "languages": {
@@ -247,7 +247,7 @@ For an oh-my-openagent-style workflow, run `/ultrawork` or `/ulw` to ask the par
247
247
 
248
248
  ### Private browser QA and project auth
249
249
 
250
- The built-in `browser-qa` role runs on `openai-codex/gpt-5.4-mini`, with
250
+ The built-in `browser-qa` role runs on `openai-codex/gpt-5.6-luna`, with
251
251
  `antigravity/gemini-3-flash-preview` and then `zai/glm-5.3` as fallbacks. Its browser
252
252
  workflow is an explicit private skill under `src/async-subagents/private-skills/`,
253
253
  outside normal Pi skill discovery. The role's first-class `isolatedSkills` setting launches the child with
@@ -4,7 +4,7 @@
4
4
 
5
5
  Provide a cheap, fast `browser-qa` async-subagent that reproduces browser bugs
6
6
  and proves fixes with deterministic assertions plus screenshot, video, and trace
7
- evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
7
+ evidence. The role uses `openai-codex/gpt-5.6-luna`, falling back to
8
8
  `antigravity/gemini-3-flash-preview` and then `zai/glm-5.3`.
9
9
 
10
10
  ## Private skill isolation
@@ -62,6 +62,9 @@ evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
62
62
  trusted runner implements a bounded set of navigation, interaction,
63
63
  assertion, screenshot, and auth-rejection actions and never gives the flow a
64
64
  Playwright context or credential values.
65
+ - Target discovery is a bounded preflight, not an open-ended research task. The
66
+ sub-agent invokes the runner within 45 seconds or returns `BLOCKED`; it does
67
+ not spend the full launcher budget reading source or probing prerequisites.
65
68
  - The launcher injects `PI_SUBAGENT_AGENT_DIR`, pre-creates a private
66
69
  `browser-qa/flows/` workspace, and clears stale browser QA files when an agent
67
70
  id is reused. The runner validates the directory's project/type metadata and
@@ -79,6 +82,32 @@ evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
79
82
  - Auth rejection discovered by a QA flow is reported through the
80
83
  `authRejectedIf` action so the parent gets an update-required status.
81
84
 
85
+ ## Reliability and shutdown contract
86
+
87
+ - The built-in `browser-qa` profile has a 120-second wall-clock budget unless
88
+ the caller explicitly supplies a task or spawn timeout. This bounds model
89
+ stalls as well as browser work.
90
+ - The trusted runner has its own bounded lifecycle. Browser launch, context
91
+ setup, auth, flow execution, evidence finalization, and browser shutdown must
92
+ not wait forever; a timeout reports the last started stage without exposing
93
+ flow contents or credentials.
94
+ - Trace sanitization runs in a memory-limited worker that can be terminated at
95
+ the cleanup deadline; synchronous archive work cannot defeat the watchdog.
96
+ - The launcher always writes a small sanitized `progress.jsonl` journal in the
97
+ agent directory. It records lifecycle/RPC event types and tool names, but not
98
+ prompts, tool arguments, tool results, model text, or secrets. The browser
99
+ runner writes similarly sanitized stage entries under its private workspace.
100
+ - On POSIX, newly launched agents own a process group. Settled, timed-out, and
101
+ explicitly stopped agents signal that group rather than only the Pi process;
102
+ timeout/settled shutdown escalates to `SIGKILL` after its grace period. On
103
+ Windows the existing recursive `taskkill /T /F` behavior remains in force.
104
+ - Process-tree cleanup is scoped to a launcher-created process-group marker so
105
+ an old or externally-created PID is never treated as an owned process group.
106
+ User browser sessions outside that group must not be signalled.
107
+ - Playwright can launch Chromium in its own POSIX process group. On runner
108
+ failure the runner snapshots and kills only its own descendants before it
109
+ exits, covering that detached browser tree without touching a user's browser.
110
+
82
111
  ## Acceptance criteria
83
112
 
84
113
  1. `browser-qa` resolves to the intended model/fallback and its self-contained
@@ -96,7 +125,10 @@ evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
96
125
  deleting the run removes them while persistent auth config/state remains.
97
126
  6. Completed test runs report clickable screenshot, video, and trace links
98
127
  whenever those artifacts exist.
99
- 7. Suite tests/typecheck, host checks, and suite sync pass.
128
+ 7. Timeout tests identify the last browser stage, launcher progress remains
129
+ available when full RPC logging is disabled, and process-tree tests prove a
130
+ descendant is terminated without signalling unrelated processes.
131
+ 8. Suite tests/typecheck, host checks, and suite sync pass.
100
132
 
101
133
  ## Real-browser regression test
102
134
 
@@ -71,7 +71,7 @@
71
71
  "model": "zai/glm-4.5-air",
72
72
  // Ordered router model fallbacks tried when the primary routing model is
73
73
  // unavailable or fails. The current parent model is always tried last.
74
- "fallbackModels": ["openai-codex/gpt-5.3-codex-spark"],
74
+ "fallbackModels": ["openai-codex/gpt-5.6-luna"],
75
75
  "maxTaskChars": 1200,
76
76
  "maxTokens": 512,
77
77
  "maxRetries": 3,
@@ -116,7 +116,7 @@
116
116
  "research": { "model": "zai/glm-5-turbo", "thinking": "low" },
117
117
  "docs": { "model": "zai/glm-4.5-air", "thinking": "low" },
118
118
  "frontend": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["zai/glm-5.3"], "thinking": "medium" },
119
- "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
119
+ "browser-qa": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "low" },
120
120
  "tests": { "model": "zai/glm-5-turbo", "thinking": "medium" },
121
121
  "review": { "model": "zai/glm-5.3", "thinking": "high" },
122
122
  "implement": { "model": "zai/glm-5.3", "thinking": "high" },
@@ -132,7 +132,7 @@
132
132
  "research": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "low" },
133
133
  "docs": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["zai/glm-4.5-air"], "thinking": "low" },
134
134
  "frontend": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
135
- "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
135
+ "browser-qa": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "low" },
136
136
  "tests": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "medium" },
137
137
  "review": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
138
138
  "implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
@@ -143,13 +143,13 @@
143
143
  "deep": {
144
144
  "description": "Use only enabled strong models, mixing GPT, Claude, and Gemini by role.",
145
145
  "types": {
146
- "quick": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["zai/glm-4.5-air"], "thinking": "low" },
147
- "scan": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["openai-codex/gpt-5.3-codex-spark", "zai/glm-4.5-air"], "thinking": "off" },
148
- "research": { "model": "antigravity/gemini-3.1-pro-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "medium" },
149
- "docs": { "model": "antigravity/gemini-2.5-flash", "fallbackModels": ["openai-codex/gpt-5.3-codex-spark", "zai/glm-4.5-air"], "thinking": "medium" },
150
- "frontend": { "model": "antigravity/gemini-3.1-pro-preview-customtools", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5.3"], "thinking": "low" },
151
- "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
152
- "tests": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "high" },
146
+ "quick": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["zai/glm-4.5-air"], "thinking": "low" },
147
+ "scan": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-4.5-air"], "thinking": "off" },
148
+ "research": { "model": "antigravity/gemini-3.1-pro-preview", "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-5-turbo"], "thinking": "medium" },
149
+ "docs": { "model": "antigravity/gemini-2.5-flash", "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-4.5-air"], "thinking": "medium" },
150
+ "frontend": { "model": "antigravity/gemini-3.1-pro-preview-customtools", "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-5.3"], "thinking": "low" },
151
+ "browser-qa": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "low" },
152
+ "tests": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-5-turbo"], "thinking": "high" },
153
153
  "review": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.6-sol", "zai/glm-5.3"], "thinking": "high" },
154
154
  "implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
155
155
  "deep": { "model": "antigravity/antigravity-claude-opus-4-6-thinking", "fallbackModels": ["openai-codex/gpt-5.6-sol", "zai/glm-5.3"], "thinking": "high" }
@@ -160,34 +160,38 @@
160
160
  "types": {
161
161
  "quick": {
162
162
  "description": "Use for tiny cheap tasks: answer a simple question, inspect one known file, or verify one fact. Not for broad repo search.",
163
- "model": "zai/glm-5-turbo",
163
+ "model": "openai-codex/gpt-5.6-luna",
164
+ "fallbackModels": ["zai/glm-4.5-air"],
164
165
  "thinking": "off"
165
166
  },
166
167
 
167
168
  "scan": {
168
169
  "description": "Use for finding files, symbols, text, or inventory across a repo. Return paths/facts; do not judge code quality.",
169
- "model": "zai/glm-5-turbo",
170
+ "model": "openai-codex/gpt-5.6-luna",
171
+ "fallbackModels": ["zai/glm-4.5-air"],
170
172
  "thinking": "off",
171
173
  "tools": ["read", "grep"]
172
174
  },
173
175
 
174
176
  "research": {
175
177
  "description": "Use for multi-file codebase research: read several files and explain how something works. No edits.",
176
- "model": "zai/glm-5-turbo",
178
+ "model": "openai-codex/gpt-5.6-terra",
179
+ "fallbackModels": ["zai/glm-5-turbo"],
177
180
  "thinking": "low",
178
181
  "tools": ["read", "grep"]
179
182
  },
180
183
 
181
184
  "docs": {
182
185
  "description": "Use for documentation work: README/API docs review, docs gaps, changelog, migration notes, examples.",
183
- "model": "zai/glm-5-turbo",
186
+ "model": "openai-codex/gpt-5.6-luna",
187
+ "fallbackModels": ["zai/glm-4.5-air"],
184
188
  "thinking": "low"
185
189
  },
186
190
 
187
191
  "frontend": {
188
192
  "description": "Use for frontend UI/UX visual work: styling, layout, typography, animation, responsive states, component polish, accessibility. Avoid backend/business logic unless needed for UI behavior.",
189
193
  "model": "antigravity/gemini-3-flash-preview",
190
- "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5.3"],
194
+ "fallbackModels": ["openai-codex/gpt-5.6-luna", "zai/glm-5.3"],
191
195
  "thinking": "medium",
192
196
  "promptAppend": [
193
197
  "Act as a frontend UI/UX engineer for visual and product-facing work.",
@@ -199,15 +203,17 @@
199
203
 
200
204
  "browser-qa": {
201
205
  "description": "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
202
- "model": "openai-codex/gpt-5.4-mini",
206
+ "model": "openai-codex/gpt-5.6-luna",
203
207
  "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
204
- "thinking": "medium",
208
+ "thinking": "low",
209
+ "timeoutMs": 120000,
205
210
  "tools": ["read", "grep", "bash"]
206
211
  },
207
212
 
208
213
  "tests": {
209
214
  "description": "Use for tests: locate coverage, find gaps, run/check targeted test commands, diagnose failing tests.",
210
- "model": "zai/glm-5-turbo",
215
+ "model": "openai-codex/gpt-5.6-terra",
216
+ "fallbackModels": ["zai/glm-5-turbo"],
211
217
  "thinking": "medium",
212
218
  "tools": ["read", "grep", "bash"]
213
219
  },
@@ -167,7 +167,7 @@ export const DEFAULT_MAX_CONCURRENT = 5;
167
167
  export const DEFAULT_ROUTING_CONFIG: ResolvedSubagentRoutingConfig = {
168
168
  enabled: true,
169
169
  model: "zai/glm-4.5-air",
170
- fallbackModels: ["openai-codex/gpt-5.3-codex-spark"],
170
+ fallbackModels: ["openai-codex/gpt-5.6-luna"],
171
171
  maxTaskChars: 1200,
172
172
  maxTokens: 512,
173
173
  maxRetries: 3,
@@ -181,18 +181,26 @@ const BUILTIN_CONFIG: SubagentConfig = {
181
181
  types: {
182
182
  quick: {
183
183
  description: "Use for tiny cheap tasks: answer a simple question, inspect one known file, or verify one fact. Not for broad repo search.",
184
+ model: "openai-codex/gpt-5.6-luna",
185
+ fallbackModels: ["zai/glm-4.5-air"],
184
186
  thinking: "off",
185
187
  },
186
188
  scan: {
187
189
  description: "Use for finding files, symbols, text, or inventory across a repo. Return paths/facts; do not judge code quality.",
190
+ model: "openai-codex/gpt-5.6-luna",
191
+ fallbackModels: ["zai/glm-4.5-air"],
188
192
  thinking: "off",
189
193
  },
190
194
  research: {
191
195
  description: "Use for multi-file codebase research: read several files and explain how something works. No edits.",
196
+ model: "openai-codex/gpt-5.6-terra",
197
+ fallbackModels: ["zai/glm-5-turbo"],
192
198
  thinking: "low",
193
199
  },
194
200
  docs: {
195
201
  description: "Use for documentation work: README/API docs review, docs gaps, changelog, migration notes, examples.",
202
+ model: "openai-codex/gpt-5.6-luna",
203
+ fallbackModels: ["zai/glm-4.5-air"],
196
204
  thinking: "low",
197
205
  },
198
206
  frontend: {
@@ -207,9 +215,10 @@ const BUILTIN_CONFIG: SubagentConfig = {
207
215
  },
208
216
  "browser-qa": {
209
217
  description: "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
210
- model: "openai-codex/gpt-5.4-mini",
218
+ model: "openai-codex/gpt-5.6-luna",
211
219
  fallbackModels: ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
212
- thinking: "medium",
220
+ thinking: "low",
221
+ timeoutMs: 120_000,
213
222
  tools: ["read", "grep", "bash"],
214
223
  isolatedSkills: [getBrowserQaSkillPath()],
215
224
  },
@@ -219,6 +228,8 @@ const BUILTIN_CONFIG: SubagentConfig = {
219
228
  },
220
229
  tests: {
221
230
  description: "Use for tests: locate coverage, find gaps, run/check targeted test commands, diagnose failing tests.",
231
+ model: "openai-codex/gpt-5.6-terra",
232
+ fallbackModels: ["zai/glm-5-turbo"],
222
233
  thinking: "medium",
223
234
  },
224
235
  review: {
@@ -13,3 +13,22 @@ export function terminateProcess(pid: number, signal: ProcessSignal): void {
13
13
  }
14
14
  process.kill(pid, signal);
15
15
  }
16
+
17
+ /** Signal a launcher-owned process tree. POSIX callers must create the child as
18
+ * a process-group leader before using this helper. */
19
+ export function terminateProcessTree(pid: number, signal: ProcessSignal): void {
20
+ if (process.platform === "win32") {
21
+ terminateProcess(pid, signal);
22
+ return;
23
+ }
24
+
25
+ try {
26
+ process.kill(-pid, signal);
27
+ } catch (error) {
28
+ const code = typeof error === "object" && error && "code" in error
29
+ ? String((error as { code?: unknown }).code)
30
+ : undefined;
31
+ if (code !== "ESRCH") throw error;
32
+ // The owned group is already gone. Never fall back to a possibly reused PID.
33
+ }
34
+ }
@@ -6,7 +6,7 @@ import { selectSuitableToolsForModel } from "../../lib/tool-args.js";
6
6
  import { validateBasename } from "./paths.js";
7
7
  import { getPiInvocation } from "./pi-invocation.js";
8
8
  import { writePromptFile } from "./prompt.js";
9
- import { terminateProcess } from "./process.js";
9
+ import { terminateProcessTree } from "./process.js";
10
10
  import { getAgentSessionDir, SUBAGENT_PARENT_SESSION_FILE, SUBAGENT_RETURN_SESSION_FILE, SUBAGENT_SESSION_FILE, writeParentSessionLink, writeSessionFileLink } from "./sessions.js";
11
11
  import { getAgentState } from "./state.js";
12
12
  import { writeStructuredResult } from "./structured-result.js";
@@ -31,6 +31,7 @@ const AGENT_TIMEOUT_KILL_GRACE_MS = 5_000;
31
31
  const AGENT_SETTLED_TERMINATE_GRACE_MS = 50;
32
32
  const AGENT_SETTLED_COMPLETION_FALLBACK_MS = 1_000;
33
33
  const EXIT_STDIO_FLUSH_GRACE_MS = 10;
34
+ const PROGRESS_LOG_MAX_BYTES = 1024 * 1024;
34
35
 
35
36
  export function shouldPersistSubagentSessions(env: NodeJS.ProcessEnv = process.env): boolean {
36
37
  return isTruthyEnv(env.ASYNC_SUBAGENTS_ENABLE_SESSIONS);
@@ -136,11 +137,16 @@ export function spawnAgent(
136
137
  const invocation = getPiInvocation(piArgs);
137
138
  const stderrStream = createDeferredFileWriter(stderrFile, logLimits.stderrMaxBytes, "stderr.log");
138
139
  const transcriptStream = createBoundedFileWriter(transcriptFile, logLimits.eventsMaxBytes, "events.jsonl");
140
+ const progressStream = createBoundedFileWriter(path.join(agentDir, "progress.jsonl"), PROGRESS_LOG_MAX_BYTES, "progress.jsonl");
141
+ const writeProgress = (stage: string, details: Record<string, unknown> = {}) => {
142
+ progressStream.write(serializeJsonLine({ at: isoNow(), stage, ...details }));
143
+ };
139
144
 
140
145
  const proc = spawn(invocation.command, invocation.args, {
141
146
  cwd,
142
147
  env: subagentEnvironment(process.env, task.subagentType === "browser-qa" ? agentDir : undefined),
143
148
  stdio: ["pipe", "pipe", "pipe"],
149
+ detached: process.platform !== "win32",
144
150
  });
145
151
  proc.stdin.on("error", (error: NodeJS.ErrnoException) => {
146
152
  if (error.code === "EPIPE") return;
@@ -156,18 +162,33 @@ export function spawnAgent(
156
162
  let timeoutKillTimer: NodeJS.Timeout | undefined;
157
163
  let agentSettledKillTimer: NodeJS.Timeout | undefined;
158
164
  let agentSettledCompletionFallbackTimer: NodeJS.Timeout | undefined;
165
+ let processExitTreeKillTimer: NodeJS.Timeout | undefined;
159
166
  let exitFinalizationTimer: NodeJS.Timeout | undefined;
167
+ let processTermination: { code: number | null; signal: NodeJS.Signals | null } | undefined;
168
+ let stdoutEnded = false;
160
169
  const suppressedRpcEventCounts = new Map<string, number>();
170
+ const scheduleProcessTreeKill = (reason: string) => {
171
+ if (processExitTreeKillTimer) return;
172
+ processExitTreeKillTimer = setTimeout(() => {
173
+ try {
174
+ writeProgress("shutdown_signal", { reason, signal: "SIGKILL" });
175
+ terminateChildProcessTree(proc, "SIGKILL");
176
+ } catch {
177
+ /* process group may already be gone */
178
+ }
179
+ }, AGENT_SETTLED_COMPLETION_FALLBACK_MS);
180
+ processExitTreeKillTimer.unref?.();
181
+ };
161
182
 
162
183
  const notifyComplete = (exitCode: number) => {
163
184
  if (completionNotified) return;
164
185
  if (exitCode !== 0) shouldKeepStderr = true;
165
186
  completionNotified = true;
166
187
  if (timeoutTimer) clearTimeout(timeoutTimer);
167
- if (timeoutKillTimer) clearTimeout(timeoutKillTimer);
168
188
  if (agentSettledKillTimer) clearTimeout(agentSettledKillTimer);
169
- if (agentSettledCompletionFallbackTimer) clearTimeout(agentSettledCompletionFallbackTimer);
170
189
  if (exitFinalizationTimer) clearTimeout(exitFinalizationTimer);
190
+ writeProgress("completed", { exitCode });
191
+ progressStream.end();
171
192
  if (!fs.existsSync(agentDir)) {
172
193
  onComplete?.({
173
194
  runDir,
@@ -207,6 +228,7 @@ export function spawnAgent(
207
228
 
208
229
  const finalizeCompletion = (code: number | null, signal: NodeJS.Signals | null) => {
209
230
  if (completionNotified) return;
231
+ if (!timeoutKillTimer && !agentSettledCompletionFallbackTimer) scheduleProcessTreeKill("process_exit");
210
232
  writeSuppressedRpcEventSummary(transcriptStream, suppressedRpcEventCounts);
211
233
  const exitCode = resolveAgentExitCode({
212
234
  timedOut,
@@ -233,19 +255,21 @@ export function spawnAgent(
233
255
  agentSettledKillTimer = setTimeout(() => {
234
256
  agentSettledKillTimer = undefined;
235
257
  try {
236
- terminateChildProcess(proc, "SIGTERM");
258
+ writeProgress("shutdown_signal", { reason: "agent_settled", signal: "SIGTERM" });
259
+ terminateChildProcessTree(proc, "SIGTERM");
237
260
  } catch {
238
261
  /* process may have exited before the graceful termination timer fired */
239
262
  }
240
263
  }, AGENT_SETTLED_TERMINATE_GRACE_MS);
241
264
  agentSettledKillTimer.unref?.();
242
265
  agentSettledCompletionFallbackTimer = setTimeout(() => {
243
- if (completionNotified) return;
244
266
  try {
245
- terminateChildProcess(proc, "SIGKILL");
267
+ writeProgress("shutdown_signal", { reason: "agent_settled", signal: "SIGKILL" });
268
+ terminateChildProcessTree(proc, "SIGKILL");
246
269
  } catch {
247
270
  /* process may already be gone */
248
271
  }
272
+ if (completionNotified) return;
249
273
  proc.stdin.destroy();
250
274
  proc.stdout?.destroy();
251
275
  proc.stderr?.destroy();
@@ -261,6 +285,7 @@ export function spawnAgent(
261
285
  if (completionNotified) return;
262
286
  timedOut = true;
263
287
  const timeoutMessage = `Sub-agent timed out after ${Math.round(timeoutMs / 1000)} seconds.`;
288
+ writeProgress("timeout", { timeoutMs });
264
289
  if (fs.existsSync(agentDir)) {
265
290
  fs.writeFileSync(path.join(agentDir, "timeout_ms"), String(timeoutMs), "utf-8");
266
291
  fs.writeFileSync(path.join(agentDir, "timed_out_at"), isoNow(), "utf-8");
@@ -269,14 +294,15 @@ export function spawnAgent(
269
294
  stderrStream.write(`${timeoutMessage}\n`);
270
295
  }
271
296
  try {
272
- terminateChildProcess(proc, "SIGTERM");
297
+ writeProgress("shutdown_signal", { reason: "timeout", signal: "SIGTERM" });
298
+ terminateChildProcessTree(proc, "SIGTERM");
273
299
  } catch {
274
300
  /* process may have exited between the timer and signal */
275
301
  }
276
302
  timeoutKillTimer = setTimeout(() => {
277
- if (completionNotified) return;
278
303
  try {
279
- terminateChildProcess(proc, "SIGKILL");
304
+ writeProgress("shutdown_signal", { reason: "timeout", signal: "SIGKILL" });
305
+ terminateChildProcessTree(proc, "SIGKILL");
280
306
  } catch {
281
307
  /* process may have exited after SIGTERM */
282
308
  }
@@ -297,6 +323,8 @@ export function spawnAgent(
297
323
  const event = JSON.parse(line) as RpcEventRecord;
298
324
  const storedEvent = compactRpcEventForTranscript(event, Buffer.byteLength(line, "utf8"));
299
325
  if (storedEvent) transcriptStream.write(serializeJsonLine(storedEvent));
326
+ const progressEvent = compactRpcEventForProgress(event);
327
+ if (progressEvent) writeProgress("rpc_event", progressEvent);
300
328
  onRpcEvent?.(event);
301
329
  const sessionFile = extractSessionFileFromEvent(event);
302
330
  if (sessionFile) writeSessionFileLink(agentDir, sessionFile);
@@ -307,8 +335,13 @@ export function spawnAgent(
307
335
  if (event.type === "response" && event.command === "prompt" && event.success === false) {
308
336
  const errorText = typeof event.error === "string" ? event.error : "RPC prompt failed";
309
337
  fs.writeFileSync(path.join(agentDir, "result.md"), errorText, "utf-8");
338
+ try {
339
+ terminateChildProcessTree(proc, "SIGTERM");
340
+ } catch {
341
+ /* process may have exited immediately after emitting the failure */
342
+ }
343
+ scheduleProcessTreeKill("prompt_failed");
310
344
  notifyComplete(1);
311
- terminateChildProcess(proc, "SIGTERM");
312
345
  return;
313
346
  }
314
347
  if (event.type === "agent_end") {
@@ -359,10 +392,29 @@ export function spawnAgent(
359
392
  },
360
393
  });
361
394
 
362
- proc.once("exit", (code, signal) => {
363
- exitFinalizationTimer = setTimeout(() => finalizeCompletion(code, signal), EXIT_STDIO_FLUSH_GRACE_MS);
395
+ // Bun on Windows may emit the ChildProcess `close` event before the stdout
396
+ // Readable has delivered its final buffered `data`/`end` events. Gate process
397
+ // finalization on stdout itself so a trailing RPC failure cannot be mistaken
398
+ // for a clean exit.
399
+ const finalizeAfterStdout = () => {
400
+ if (!processTermination || !stdoutEnded || completionNotified || exitFinalizationTimer) return;
401
+ const { code, signal } = processTermination;
402
+ exitFinalizationTimer = setTimeout(
403
+ () => finalizeCompletion(code, signal),
404
+ EXIT_STDIO_FLUSH_GRACE_MS,
405
+ );
364
406
  exitFinalizationTimer.unref?.();
407
+ };
408
+ const recordProcessTermination = (code: number | null, signal: NodeJS.Signals | null) => {
409
+ processTermination ??= { code, signal };
410
+ finalizeAfterStdout();
411
+ };
412
+ proc.stdout.once("end", () => {
413
+ stdoutEnded = true;
414
+ finalizeAfterStdout();
365
415
  });
416
+ proc.once("exit", recordProcessTermination);
417
+ proc.once("close", recordProcessTermination);
366
418
 
367
419
  proc.once("error", (error) => {
368
420
  const message = String(error);
@@ -376,6 +428,11 @@ export function spawnAgent(
376
428
  notifyComplete(1);
377
429
  });
378
430
 
431
+ const pid = proc.pid!;
432
+ fs.writeFileSync(path.join(agentDir, "pid"), String(pid), "utf-8");
433
+ if (process.platform !== "win32") fs.writeFileSync(path.join(agentDir, "process_group"), String(pid), "utf-8");
434
+ writeProgress("spawned", { pid });
435
+
379
436
  proc.stdin.write([
380
437
  serializeJsonLine({
381
438
  id: "sub_get_state",
@@ -388,6 +445,7 @@ export function spawnAgent(
388
445
  ...(promptImages ? { images: promptImages } : {}),
389
446
  }),
390
447
  ].join(""));
448
+ writeProgress("prompt_sent");
391
449
  // Keep stdin open while the RPC prompt is running. pi RPC mode treats stdin
392
450
  // EOF as a shutdown request, while the prompt command itself is handled
393
451
  // asynchronously after preflight. Closing stdin here can therefore terminate
@@ -395,9 +453,6 @@ export function spawnAgent(
395
453
  // producing exit 0 with no result.md. The child is terminated explicitly after agent_settled,
396
454
  // timeout, stop, or process error.
397
455
 
398
- const pid = proc.pid!;
399
- fs.writeFileSync(path.join(agentDir, "pid"), String(pid), "utf-8");
400
-
401
456
  return { pid, agentDir, process: proc };
402
457
  }
403
458
 
@@ -423,9 +478,9 @@ function getAntigravityAuthExtensionPath(): string {
423
478
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "antigravity-auth", "index.ts");
424
479
  }
425
480
 
426
- function terminateChildProcess(proc: ChildProcess, signal: NodeJS.Signals): void {
481
+ function terminateChildProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
427
482
  if (proc.pid) {
428
- terminateProcess(proc.pid, signal as "SIGTERM" | "SIGINT" | "SIGKILL");
483
+ terminateProcessTree(proc.pid, signal as "SIGTERM" | "SIGINT" | "SIGKILL");
429
484
  return;
430
485
  }
431
486
  proc.kill(signal);
@@ -603,6 +658,31 @@ function compactRpcEventForTranscript(event: RpcEventRecord, originalBytes: numb
603
658
  return { type: event.type, bytes: originalBytes };
604
659
  }
605
660
 
661
+ function compactRpcEventForProgress(event: RpcEventRecord): Record<string, unknown> | undefined {
662
+ if (event.type === "message_update" || event.type === "tool_execution_update") return undefined;
663
+ if (event.type === "response") {
664
+ return stripUndefined({
665
+ type: event.type,
666
+ command: typeof event.command === "string" ? event.command : undefined,
667
+ success: typeof event.success === "boolean" ? event.success : undefined,
668
+ });
669
+ }
670
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
671
+ return stripUndefined({
672
+ type: event.type,
673
+ toolName: typeof event.toolName === "string" ? event.toolName : undefined,
674
+ });
675
+ }
676
+ if (event.type === "message_end") {
677
+ return stripUndefined({
678
+ type: event.type,
679
+ role: isRecord(event.message) && typeof event.message.role === "string" ? event.message.role : undefined,
680
+ stopReason: isRecord(event.message) && typeof event.message.stopReason === "string" ? event.message.stopReason : undefined,
681
+ });
682
+ }
683
+ return { type: event.type };
684
+ }
685
+
606
686
  function suppressedRpcEventType(line: string): string | undefined {
607
687
  if (line.includes('"type":"message_update"') || line.includes('"type": "message_update"')) return "message_update";
608
688
  if (line.includes('"type":"tool_execution_update"') || line.includes('"type": "tool_execution_update"')) return "tool_execution_update";
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getAgentState, getRunState } from "./state.js";
4
- import { terminateProcess } from "./process.js";
4
+ import { terminateProcess, terminateProcessTree } from "./process.js";
5
5
  import { writeStructuredResult } from "./structured-result.js";
6
6
  import type { AgentState } from "./types.js";
7
7
  import { isoNow } from "./utils.js";
@@ -70,7 +70,9 @@ function stopAgent(runDir: string, agent: AgentState, signal: StopSignal): StopA
70
70
  }
71
71
 
72
72
  try {
73
- terminateProcess(agent.pid, signal);
73
+ const ownsProcessGroup = fs.existsSync(path.join(runDir, agent.id, "process_group"));
74
+ if (ownsProcessGroup) terminateProcessTree(agent.pid, signal);
75
+ else terminateProcess(agent.pid, signal);
74
76
  markStopped(runDir, agent.id, signal);
75
77
  return {
76
78
  ...result,