pi-crew 0.10.2 → 0.10.4

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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -0,0 +1,400 @@
1
+ /**
2
+ * tmux SurfaceProvider (spec §4)
3
+ *
4
+ * Port surface logic nhánh tmux từ pi-interactive-subagents, viết lại theo
5
+ * interface pi-crew. Trong team run (tabKey): một run = một window (tab),
6
+ * worker split dọc/ngang xen kẽ tối đa MAX_PANES_PER_TAB pane. Ngoài run:
7
+ * một worker = một pane split ngang từ pane cha ($TMUX_PANE) để pane đi theo
8
+ * agent thay vì theo focus của user.
9
+ * Launch command đã build sẵn ("bash <script-path>") — gửi vào pane qua
10
+ * send-keys; script tự lo cwd (spec §5.2, Task 5).
11
+ *
12
+ * onExit: MỘT interval 2s cho cả provider — mỗi tick gọi list-panes đúng
13
+ * một lần rồi đối chiếu mọi pane đang được theo dõi (không spawn N subprocess
14
+ * cho N pane). Pane biến khỏi list hoặc pane_dead=1 → "pane-closed" bắn đúng
15
+ * một lần. tmux call throw (binary mất đột ngột, server chết) → "mux-dead"
16
+ * cho mọi handle còn sống.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+
21
+ import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
22
+ import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
23
+
24
+ /** Chờ process trong pane chết sau SIGTERM trước khi force kill-pane (spec §4). */
25
+ const GRACEFUL_TERM_WAIT_MS = 3000;
26
+
27
+ /** Chu kỳ poll list-panes cho onExit (spec §4). */
28
+ const EXIT_POLL_INTERVAL_MS = 2000;
29
+
30
+ /** Timer handle trả về từ deps.schedule. */
31
+ export interface SurfaceTimer {
32
+ clear(): void;
33
+ }
34
+
35
+ /** Dependencies injectable — mọi I/O đều thay được để unit test không chạm tmux thật. */
36
+ export interface TmuxProviderDeps {
37
+ /** Chạy `tmux <args>`, trả stdout. Throw khi exit != 0 hoặc binary mất. */
38
+ tmux?: (args: string[]) => string;
39
+ /** Env nguồn TMUX / TMUX_PANE (default process.env). */
40
+ env?: NodeJS.ProcessEnv;
41
+ /** Ngủ chờ graceful close (default setTimeout). */
42
+ sleep?: (ms: number) => Promise<void>;
43
+ /** SIGTERM process trong pane theo pid (default process.kill SIGTERM). */
44
+ killTree?: (pid: number) => void;
45
+ /** Lập lịch poll onExit (default setInterval + unref). Test gọi tick thủ công. */
46
+ schedule?: (fn: () => void, ms: number) => SurfaceTimer;
47
+ /** Probe binary cho detect (default `command -v tmux`, có cache). */
48
+ hasCommand?: (bin: string) => boolean;
49
+ }
50
+
51
+ /** Binary availability cache — `command -v` là subprocess, memoize như resolve-surface. */
52
+ const binaryAvailability = new Map<string, boolean>();
53
+
54
+ function defaultHasCommand(bin: string): boolean {
55
+ const cached = binaryAvailability.get(bin);
56
+ if (cached !== undefined) return cached;
57
+ let available = false;
58
+ try {
59
+ execFileSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" });
60
+ available = true;
61
+ } catch {
62
+ available = false;
63
+ }
64
+ binaryAvailability.set(bin, available);
65
+ return available;
66
+ }
67
+
68
+ function defaultSchedule(fn: () => void, ms: number): SurfaceTimer {
69
+ const timer = setInterval(fn, ms);
70
+ timer.unref();
71
+ return { clear: () => clearInterval(timer) };
72
+ }
73
+
74
+ /** Một pane đang được theo dõi onExit. */
75
+ interface PaneWatcher {
76
+ callbacks: Array<(reason: SurfaceExitReason) => void>;
77
+ exited: boolean;
78
+ reason?: SurfaceExitReason;
79
+ }
80
+
81
+ /** Parse `list-panes -F '#{pane_dead} #{pane_id}'` → Map id → dead. */
82
+ function parsePaneStatus(stdout: string): Map<string, boolean> {
83
+ const status = new Map<string, boolean>();
84
+ for (const line of stdout.split("\n")) {
85
+ const match = line.trim().match(/^(\d+)\s+(\S+)$/);
86
+ if (match) status.set(match[2], match[1] === "1");
87
+ }
88
+ return status;
89
+ }
90
+
91
+ /** Tìm pid của pane trong `list-panes -F '#{pane_pid} #{pane_id}'`. */
92
+ function findPanePid(stdout: string, paneId: string): number | null {
93
+ for (const line of stdout.split("\n")) {
94
+ const match = line.trim().match(/^(\d+)\s+(\S+)$/);
95
+ if (match && match[2] === paneId) {
96
+ const pid = Number(match[1]);
97
+ return Number.isFinite(pid) ? pid : null;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider {
104
+ const tmux = deps.tmux ?? ((args: string[]) => execFileSync("tmux", args, { encoding: "utf8" }));
105
+ const env = deps.env ?? process.env;
106
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
107
+ const killTree = deps.killTree ?? ((pid: number) => process.kill(pid, "SIGTERM"));
108
+ const hasCommand = deps.hasCommand ?? defaultHasCommand;
109
+ const schedule = deps.schedule ?? defaultSchedule;
110
+
111
+ // Watcher theo pane id — MỘT timer chung cho mọi pane đang theo dõi.
112
+ const watchers = new Map<string, PaneWatcher>();
113
+ let timer: SurfaceTimer | null = null;
114
+
115
+ // Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → MỌI window
116
+ // của run + số pane đã spawn trong window đang nhận. Tab mở khi run spawn
117
+ // worker đầu, KHÔNG đóng khi từng worker xong — chỉ đóng khi run end
118
+ // (closeTab, Task 5). Giữ array vì run dài có thể vượt MAX_PANES_PER_TAB
119
+ // và mở window kế — closeTab phải dọn cả window cũ, không chỉ window cuối.
120
+ const tabWindows = new Map<string, { windows: string[]; paneCount: number }>();
121
+
122
+ function activeWatchers(): number {
123
+ let active = 0;
124
+ for (const watcher of watchers.values()) if (!watcher.exited) active += 1;
125
+ return active;
126
+ }
127
+
128
+ function ensureTimer(): void {
129
+ if (timer || activeWatchers() === 0) return;
130
+ timer = schedule(pollExits, EXIT_POLL_INTERVAL_MS);
131
+ }
132
+
133
+ function maybeStopTimer(): void {
134
+ if (!timer || activeWatchers() > 0) return;
135
+ timer.clear();
136
+ timer = null;
137
+ }
138
+
139
+ /** Bắn reason MỘT lần cho mọi callback của pane (copy list — cb có thể dispose). */
140
+ function fire(paneId: string, reason: SurfaceExitReason): void {
141
+ const watcher = watchers.get(paneId);
142
+ if (!watcher || watcher.exited) return;
143
+ watcher.exited = true;
144
+ watcher.reason = reason;
145
+ for (const cb of [...watcher.callbacks]) cb(reason);
146
+ maybeStopTimer();
147
+ }
148
+
149
+ function pollExits(): void {
150
+ let stdout: string;
151
+ try {
152
+ // -a: liệt kê panes toàn server — pane id là duy nhất toàn server,
153
+ // còn không -a thì chỉ thấy current window (pane-closed giả khi user
154
+ // switch window giữa chừng).
155
+ stdout = tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]);
156
+ } catch {
157
+ // tmux binary mất đột ngột / server chết — mọi handle còn sống thành mux-dead.
158
+ for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
159
+ return;
160
+ }
161
+ const status = parsePaneStatus(stdout);
162
+ for (const [paneId, watcher] of [...watchers]) {
163
+ // Không có trong list (đã dọn) hoặc pane_dead=1 → đóng.
164
+ if (!watcher.exited && status.get(paneId) !== false) fire(paneId, "pane-closed");
165
+ }
166
+ }
167
+
168
+ function makeHandle(paneId: string, tabId?: string): SurfaceHandle {
169
+ return {
170
+ id: paneId,
171
+ kind: "tmux",
172
+ ...(tabId ? { tabId } : {}),
173
+ onExit(cb) {
174
+ let watcher = watchers.get(paneId);
175
+ if (!watcher) {
176
+ watcher = { callbacks: [], exited: false };
177
+ watchers.set(paneId, watcher);
178
+ }
179
+ watcher.callbacks.push(cb);
180
+ // Đăng ký sau exit → replay reason ngay để không mất event.
181
+ if (watcher.exited) {
182
+ cb(watcher.reason as SurfaceExitReason);
183
+ return;
184
+ }
185
+ ensureTimer();
186
+ },
187
+ dispose() {
188
+ const watcher = watchers.get(paneId);
189
+ if (!watcher) return;
190
+ // Host chủ động dispose khi pane còn sống → "detached" cho listener.
191
+ if (!watcher.exited) fire(paneId, "detached");
192
+ watchers.delete(paneId);
193
+ maybeStopTimer();
194
+ },
195
+ };
196
+ }
197
+
198
+ function assertTmuxHandle(handle: SurfaceHandle): void {
199
+ if (handle.kind !== "tmux") {
200
+ throw new Error(`Expected a tmux handle, got kind "${handle.kind}" (id ${handle.id})`);
201
+ }
202
+ }
203
+
204
+ /** Pane còn trong list (toàn server) và chưa dead. tmux throw (mux chết) → false. */
205
+ function isPaneAlive(paneId: string): boolean {
206
+ try {
207
+ return parsePaneStatus(tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"])).get(paneId) === false;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /** kill-pane best-effort — pane có thể tự chết trong lúc chờ SIGTERM. */
214
+ function killPaneBestEffort(paneId: string): void {
215
+ try {
216
+ tmux(["kill-pane", "-t", paneId]);
217
+ } catch {
218
+ // Pane đã mất — coi như đã đóng.
219
+ }
220
+ }
221
+
222
+ return {
223
+ kind: "tmux",
224
+
225
+ detect(): SurfaceDetection {
226
+ if (!env.TMUX) {
227
+ return { ok: false, reason: "TMUX env not set — pi is not running inside a tmux session" };
228
+ }
229
+ if (!hasCommand("tmux")) {
230
+ return { ok: false, reason: "tmux binary not found on PATH" };
231
+ }
232
+ return { ok: true, kind: "tmux" };
233
+ },
234
+
235
+ async createSurface(_name: string, opts: SurfaceSpawnOpts): Promise<SurfaceHandle> {
236
+ let targetWindow: string;
237
+ let directionFlag: string;
238
+ // Task 5: window (tab) chứa pane này — None khi đường legacy.
239
+ let tabId: string | undefined;
240
+ // Tab-map chỉ ghi sau khi split-window THÀNH CÔNG — nếu split fail,
241
+ // paneCount không đếm pane không tồn tại (luân phiên không lệch bước
242
+ // ở lần retry kế tiếp).
243
+ let commitTabPane: (() => void) | null = null;
244
+ if (opts.tabKey) {
245
+ // Tab-layout: mọi worker của cùng run chia 1 window (tab), split
246
+ // dọc/ngang xen kẽ theo pane index trong tab (spec tab-layout §4).
247
+ const tabKey = opts.tabKey;
248
+ const existing = tabWindows.get(tabKey);
249
+ if (existing && existing.paneCount < MAX_PANES_PER_TAB) {
250
+ const paneIndexInTab = existing.paneCount;
251
+ const currentWindowId = existing.windows[existing.windows.length - 1] as string;
252
+ targetWindow = currentWindowId;
253
+ tabId = currentWindowId;
254
+ directionFlag = splitDirectionFor(paneIndexInTab) === "down" ? "-v" : "-h";
255
+ commitTabPane = () => {
256
+ existing.paneCount = paneIndexInTab + 1;
257
+ };
258
+ } else {
259
+ // Tab mới cho run (hoặc tab cũ đã đầy MAX_PANES_PER_TAB) — window riêng.
260
+ // -d: window mới không thành current window — không steal focus client.
261
+ const windowId = tmux(["new-window", "-d", "-P", "-F", "#{window_id}"]).trim();
262
+ if (!/^@\d+$/.test(windowId)) {
263
+ throw new Error(`Unexpected tmux new-window output: ${JSON.stringify(windowId)}`);
264
+ }
265
+ const label = opts.title ?? tabKey;
266
+ try {
267
+ tmux(["rename-window", "-t", windowId, label]);
268
+ } catch {
269
+ // rename là cosmetic — pane vẫn dùng được.
270
+ }
271
+ targetWindow = windowId;
272
+ tabId = windowId;
273
+ directionFlag = splitDirectionFor(0) === "down" ? "-v" : "-h";
274
+ const priorWindows = existing?.windows ?? [];
275
+ commitTabPane = () => {
276
+ tabWindows.set(tabKey, { windows: [...priorWindows, windowId], paneCount: 1 });
277
+ };
278
+ }
279
+ } else {
280
+ // Đường legacy (spawn ngoài run): split từ pane cha để pane đi theo
281
+ // agent, không theo focus của user — giữ nguyên "-h" như trước.
282
+ const parentPane = env.TMUX_PANE;
283
+ if (!parentPane) {
284
+ throw new Error("TMUX_PANE not set — tmux provider chỉ chạy bên trong tmux session");
285
+ }
286
+ targetWindow = parentPane;
287
+ directionFlag = "-h";
288
+ }
289
+ const raw = tmux(["split-window", "-d", directionFlag, "-P", "-F", "#{pane_id}", "-t", targetWindow]);
290
+ const paneId = raw.trim();
291
+ if (!/^%\d+$/.test(paneId)) {
292
+ throw new Error(`Unexpected tmux split-window output: ${JSON.stringify(raw)}`);
293
+ }
294
+ commitTabPane?.();
295
+ if (opts.title) {
296
+ try {
297
+ tmux(["select-pane", "-t", paneId, "-T", opts.title]);
298
+ } catch {
299
+ // Title là cosmetic — pane vẫn dùng được.
300
+ }
301
+ }
302
+ // Command đã build sẵn ("bash <script-path>") — gửi literal rồi Enter.
303
+ // Commandless tạo pane là hợp lệ (spec §13.1: host cần pane id trước,
304
+ // build script xong mới boot worker qua sendCommand) — không gửi gì cả.
305
+ if (opts.command !== undefined) {
306
+ tmux(["send-keys", "-t", paneId, "-l", opts.command]);
307
+ tmux(["send-keys", "-t", paneId, "Enter"]);
308
+ }
309
+ return makeHandle(paneId, tabId);
310
+ },
311
+
312
+ async sendCommand(handle, text) {
313
+ assertTmuxHandle(handle);
314
+ // Gửi literal text (không tmux-interpreted) rồi Enter — giống cách
315
+ // createSurface gửi command người dùng gõ được.
316
+ tmux(["send-keys", "-t", handle.id, "-l", text]);
317
+ tmux(["send-keys", "-t", handle.id, "Enter"]);
318
+ },
319
+
320
+ attach(id: string): SurfaceHandle | null {
321
+ let status: Map<string, boolean>;
322
+ try {
323
+ status = parsePaneStatus(tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]));
324
+ } catch {
325
+ return null;
326
+ }
327
+ if (status.get(id) !== false) return null;
328
+ return makeHandle(id);
329
+ },
330
+
331
+ async readScreen(handle: SurfaceHandle, lines = 50): Promise<string> {
332
+ assertTmuxHandle(handle);
333
+ // Pane mất → tmux exit != 0 → throw (spec: throw khi handle không hợp lệ).
334
+ return tmux(["capture-pane", "-p", "-t", handle.id, "-S", `-${Math.max(1, lines)}`]);
335
+ },
336
+
337
+ async closeSurface(handle: SurfaceHandle, opts?: { force?: boolean }): Promise<void> {
338
+ assertTmuxHandle(handle);
339
+ if (opts?.force) {
340
+ killPaneBestEffort(handle.id);
341
+ return;
342
+ }
343
+ // Graceful (spec §4): SIGTERM pid trong pane → đợi 3s → force kill-pane.
344
+ if (!isPaneAlive(handle.id)) return; // đã đóng — idempotent
345
+ const pid = findPanePid(tmux(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"]), handle.id);
346
+ // pid > 1: pid 0/1 không bao giờ là process của pane — không signal.
347
+ // Không tìm được pid thì SIGTERM không thể → force luôn, không đợi vô ích.
348
+ if (pid !== null && pid > 1) {
349
+ killTree(pid);
350
+ await sleep(GRACEFUL_TERM_WAIT_MS);
351
+ }
352
+ if (isPaneAlive(handle.id)) killPaneBestEffort(handle.id);
353
+ },
354
+
355
+ /**
356
+ * Task 5 (spec tab-layout §5): run end → kill MỌI window của run theo
357
+ * map nội bộ (run dài >8 pane mở window kế — cả hai đều phải chết).
358
+ * Window đã tự đóng từ trước (pane cuối tự exit làm window biến mất)
359
+ * → kill-window throw → nuốt: idempotent.
360
+ */
361
+ async closeTab(tabKey: string): Promise<void> {
362
+ const entry = tabWindows.get(tabKey);
363
+ if (!entry) return;
364
+ tabWindows.delete(tabKey);
365
+ for (const windowId of entry.windows) {
366
+ try {
367
+ tmux(["kill-window", "-t", windowId]);
368
+ } catch {
369
+ // window đã mất — coi như đã đóng.
370
+ }
371
+ }
372
+ },
373
+
374
+ /**
375
+ * Task 6 (doctor cleanup-by-id): đóng MỘT window theo id đọc từ
376
+ * manifest.surface.tabs — doctor chạy ở process khác host đã spawn nên
377
+ * tabWindows ở đó trống (closeTab no-op). Liveness check TRƯỚC khi
378
+ * đóng (pattern pane-orphan của doctor): list-windows toàn server, dễ
379
+ * thấy window → gone, không kill-window mù; window mất giữa lúc check
380
+ * và kill → throw được nuốt thành gone (idempotent).
381
+ */
382
+ async closeTabById(tabId: string): Promise<"closed" | "gone"> {
383
+ let windows: string[];
384
+ try {
385
+ windows = tmux(["list-windows", "-a", "-F", "#{window_id}"]).trim().split("\n");
386
+ } catch (error) {
387
+ throw new Error(
388
+ `tmux list-windows failed before closing tab ${tabId}: ${error instanceof Error ? error.message : String(error)}`,
389
+ );
390
+ }
391
+ if (!windows.includes(tabId)) return "gone";
392
+ try {
393
+ tmux(["kill-window", "-t", tabId]);
394
+ } catch {
395
+ return "gone"; // window mất giữa chừng — mục tiêu đạt rồi
396
+ }
397
+ return "closed";
398
+ },
399
+ };
400
+ }
@@ -723,6 +723,39 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
723
723
  logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
724
724
  }
725
725
  },
726
+ onSurfaceActivity: (event) => {
727
+ // F1 fix (battery 2026-09-10): surface workers have no stdout pipe, so
728
+ // onJsonEvent/onStdoutLine never fire and task liveness state starves.
729
+ // Reuses the SAME throttled persisters as the stdout path:
730
+ // persistHeartbeat (1s) touches lastSeenAt + persists task state;
731
+ // persistChildProgress (coalesced) appends task.progress to the RUN
732
+ // event log - a DIFFERENT file than the per-agent log being tailed, so
733
+ // no watch->append loop. NEVER call appendCrewAgentEventBuffered here:
734
+ // the worker already wrote this event to the tailed file itself.
735
+ try {
736
+ const pidFromStart = event?.type === "worker.started" && typeof event.pid === "number" ? event.pid : undefined;
737
+ if (pidFromStart !== undefined) {
738
+ // Surface pid was previously never persisted into task state - the
739
+ // heartbeat-watcher PID-liveness gate (alive -> downgrade dead->stale)
740
+ // had nothing to check. Pin it once from the worker.started self-report.
741
+ task = {
742
+ ...task,
743
+ heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), {
744
+ pid: pidFromStart,
745
+ }),
746
+ };
747
+ }
748
+ task = {
749
+ ...task,
750
+ agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt),
751
+ };
752
+ tasks = updateTask(tasks, task);
753
+ persistHeartbeat();
754
+ persistChildProgress(event);
755
+ } catch (err) {
756
+ logInternalError("task-runner.on-surface-activity", err as Error, `taskId=${task.id}`);
757
+ }
758
+ },
726
759
  });
727
760
  } finally {
728
761
  // R10-5 (Wave 2B item 4): task-boundary flush — land every buffered
@@ -741,6 +774,53 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
741
774
  input.signal.removeEventListener("abort", externalAbortListener);
742
775
  }
743
776
  }
777
+ // ── MuxSurface A1 (spec §7 D3): degrade short-circuit ────────────────
778
+ // classifyOnExit found NO worker.completed after the pane died, so the
779
+ // worker never finished. This is NOT a model failure and NOT an empty
780
+ // result — deriving an error here would route into autoRetry (no-op
781
+ // retries + deadletter noise) AND persist a "failed" task that
782
+ // handleFailedTask could abort the whole run on. Instead: hand the loss
783
+ // to finalize as `surfaceLost` (needs_attention), leave the task
784
+ // non-terminal in the meantime, and let the team-runner drain re-queue
785
+ // it headless.
786
+ if (childResult.surface?.degraded) {
787
+ const degraded = childResult.surface.degraded;
788
+ logs.push(
789
+ `SURFACE DEGRADE: worker in ${childResult.surface.kind} pane ${childResult.surface.paneId} lost (${degraded.cause}) ` +
790
+ `with no completion within the classify window; re-dispatching headless`,
791
+ "",
792
+ );
793
+ return {
794
+ resultArtifact: undefined,
795
+ logArtifact: undefined,
796
+ transcriptArtifact: undefined,
797
+ exitCode: null,
798
+ error: undefined,
799
+ modelAttempts,
800
+ parsedOutput: undefined,
801
+ finalStdout: "",
802
+ rawFinalText: undefined,
803
+ transcriptPath,
804
+ terminalEvidence,
805
+ startupEvidence: createStartupEvidence({
806
+ command: "pi",
807
+ startedAt: attemptStartedAt,
808
+ finishedAt: new Date(),
809
+ promptSentAt: attemptStartedAt,
810
+ promptAccepted: false,
811
+ stderr: childResult.stderr,
812
+ error: undefined,
813
+ exitCode: null,
814
+ }),
815
+ surfaceLost: {
816
+ taskId: task.id,
817
+ paneId: childResult.surface.paneId,
818
+ cause: degraded.cause,
819
+ exitReason: degraded.exitReason,
820
+ ts: new Date().toISOString(),
821
+ },
822
+ };
823
+ }
744
824
  const evidenceStatus = evidenceStatusFor(childResult);
745
825
  terminalEvidence = [
746
826
  ...terminalEvidence,
@@ -56,7 +56,22 @@ import { persistSingleTaskUpdate, updateTask } from "./state-helpers.ts";
56
56
  * mutation guard (fail mode) inside finalizeTaskResult.
57
57
  */
58
58
  export interface TaskExecutionResult {
59
- resultArtifact: ArtifactDescriptor;
59
+ /**
60
+ * MuxSurface A1 (spec §7 D3): the worker's pane died with NO worker.completed
61
+ * inside the classify window — the worker never finished. When set,
62
+ * {@link finalizeTaskResult} short-circuits into a dedicated degrade
63
+ * terminalisation (needs_attention, no fabricated result) INSTEAD of the
64
+ * yield/mutation/output guards, which would misread the loss as an
65
+ * empty-result failure and trigger autoRetry double-dispatch.
66
+ */
67
+ surfaceLost?: {
68
+ taskId: string;
69
+ paneId: string;
70
+ cause: "pane-closed" | "mux-dead";
71
+ exitReason: string;
72
+ ts: string;
73
+ };
74
+ resultArtifact?: ArtifactDescriptor;
60
75
  logArtifact: ArtifactDescriptor | undefined;
61
76
  transcriptArtifact: ArtifactDescriptor | undefined;
62
77
  exitCode: number | null;
@@ -123,6 +138,46 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
123
138
  const terminalEvidence = execResult.terminalEvidence;
124
139
  const startupEvidence = execResult.startupEvidence;
125
140
 
141
+ // ── MuxSurface A1 (spec §7 D3): degrade terminalisation ───────────────
142
+ // The pane died with NO worker.completed in the classify window: the worker
143
+ // never finished, so the yield/mutation/output guards would MISREAD this as
144
+ // an empty-result failure (autoRetry would then double-dispatch and a failed
145
+ // task could abort the whole run at handleFailedTask). Terminalise as
146
+ // needs_attention WITHOUT fabricating a result; team-runner's surface-degrade
147
+ // drain re-queues the unit headless (scratchpad restore + pendingSteers
148
+ // replay + resume note) WITHOUT consuming retry budget (spec §7 step 5).
149
+ if (execResult.surfaceLost) {
150
+ const lost = execResult.surfaceLost;
151
+ task = {
152
+ ...task,
153
+ status: "needs_attention",
154
+ finishedAt: new Date().toISOString(),
155
+ exitCode: null,
156
+ error: undefined,
157
+ claim: undefined,
158
+ heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }),
159
+ workerExitStatus: terminalEvidence.at(-1)?.exitStatus,
160
+ terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence,
161
+ diagnostics: {
162
+ ...(task.diagnostics ?? {}),
163
+ surfaceLost: { ts: lost.ts, cause: lost.cause, paneId: lost.paneId, exitReason: lost.exitReason },
164
+ },
165
+ };
166
+ tasks = updateTask(tasks, task);
167
+ // ST-7: durable terminal write under the run lock — crash recovery must
168
+ // see needs_attention, not a phantom "running" worker.
169
+ tasks = await withRunLock(manifest, async () => persistSingleTaskUpdate(manifest, tasks, task, undefined, true));
170
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
171
+ await appendEventAsync(manifest.eventsPath, {
172
+ type: "task.surface_lost",
173
+ runId: manifest.runId,
174
+ taskId: task.id,
175
+ message: `Surface worker lost (${lost.cause}) before completing — re-dispatching headless`,
176
+ data: { paneId: lost.paneId, cause: lost.cause, exitReason: lost.exitReason, ts: lost.ts },
177
+ });
178
+ return { manifest, tasks };
179
+ }
180
+
126
181
  // --- Yield-based completion contract ---
127
182
  // _yieldResult: preserved for future use — yield completion contract not yet wired to task.result
128
183
  let _yieldResult: YieldResult | undefined;
@@ -538,7 +593,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
538
593
  artifacts: [
539
594
  ...manifest.artifacts,
540
595
  promptArtifact,
541
- resultArtifact,
596
+ ...(resultArtifact ? [resultArtifact] : []),
542
597
  inputsArtifact,
543
598
  coordinationArtifact,
544
599
  ...(skillArtifact ? [skillArtifact] : []),
@@ -41,6 +41,7 @@ export function coordinationBridgeInstructions(task: TeamTaskState): string {
41
41
  `Mailbox target for this task: ${task.id}`,
42
42
  "Use the run mailbox contract for coordination with the leader/orchestrator:",
43
43
  "- If blocked or uncertain, report the blocker in your final result and, when mailbox tools/API are available, send an inbox/outbox message addressed to the leader.",
44
+ "- Never guess implementation details that materially affect decisions. If the `ask` tool is available and you need a clarification, a decision, or a missing requirement before you can proceed safely, call `ask` and wait — a parked question is cheaper than a wrong build.",
44
45
  "- Ask the leader before editing when scope is ambiguous, requirements conflict, destructive action is needed, or you discover likely overlap with another task.",
45
46
  "- Before making non-trivial edits, state intended changed files in your notes/result; if another worker may touch the same file/symbol, pause and request sequencing/ownership guidance.",
46
47
  "- Do not resolve cross-worker conflicts silently. Escalate via mailbox/result with: file/symbol, conflicting task if known, proposed owner, and safest next step.",