pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -0,0 +1,374 @@
1
+ /**
2
+ * event-log-tail-source.ts — WorkerEventSource cho surface workers (spec §5.3).
3
+ *
4
+ * Headless workers phát events qua stdout JSON stream; surface workers chạy
5
+ * TUI trong pane mux — không có stream nào cả — nên worker-side recorder
6
+ * (S2-T8, prompt/surface-worker.ts) tự ghi per-agent
7
+ * `agents/{taskId}/events.jsonl` theo đúng dòng `{seq,time,event}` host vẫn
8
+ * viết (crew-agent-records.ts appendCrewAgentEvent). `EventLogTailSource` là
9
+ * nửa host của hợp đồng đó: tail file này và phát lại `line.event` — payload
10
+ * compacted pi event, tức đúng shape `bridgeEventFromJsonEvent` tiêu thụ (và
11
+ * đúng shape `ChildPiLineObserver` phát cho `onJsonEvent` ở đường headless).
12
+ *
13
+ * Đọc incremental: watcher callback (fs.watch qua watchWithErrorHandler) chỉ
14
+ * báo "có đổi" — SOURCE tự giữ byte offset (spec §5.3: caller quản position).
15
+ * stat size > offset → readSync từ offset; size shrink (truncate) → reset về
16
+ * 0; nửa dòng chưa có `\n` được giữ lại tới khi đủ. File chưa tồn tại lúc
17
+ * start là ĐƯỜNG BÌNH THƯỜNG của surface (recorder tạo file ở event đầu, thậm
18
+ * chí mkdir lười) — source bootstrap bằng poll nhẹ cho tới khi gắn được
19
+ * watcher, rồi tắt poll.
20
+ *
21
+ * FEEDBACK-LOOP GUARD (đọc trước khi nối consumer): host KHÔNG ĐƯỢC đưa events
22
+ * này vào chuỗi `onJsonEvent` của task-runner — callback đó gọi
23
+ * `appendCrewAgentEventBuffered` ghi vào CHÍNH file này (worker đã ghi rồi,
24
+ * §5.3 "transcript per-agent: worker-side recorder") → mỗi dòng bị ghi hai
25
+ * lần và watcher tự kích hoạt chính nó (watch → append → watch …) không dừng.
26
+ * Call site (child-pi.ts) chỉ bridge qua `bridgeEventFromJsonEvent` +
27
+ * `runEventBus` cho dashboard/sidebar — không đụng disk.
28
+ *
29
+ * StdoutJsonEventSource: adapter mỏng của đường headless — bọc child.stdout,
30
+ * tách dòng, compact qua `compactChildPiEvent` (cùng compaction recorder
31
+ * worker dùng) và phát event. Đường headless CHÍNH vẫn giữ nguyên wiring
32
+ * ChildPiLineObserver của nó (transcript/raw-text/steering không đổi); class
33
+ * này tồn tại để cả hai nguồn cùng implement một interface cho consumer chung.
34
+ */
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+ import type { Readable } from "node:stream";
39
+
40
+ import { closeWatcher, watchWithErrorHandler } from "../utils/fs-watch.ts";
41
+ import { logInternalError } from "../utils/internal-error.ts";
42
+ import { compactChildPiEvent } from "./child-pi/child-pi-streams.ts";
43
+
44
+ /**
45
+ * Payload cả hai source phát: compacted pi event (`{type, ...}`) — INPUT shape
46
+ * của `bridgeEventFromJsonEvent`, OUTPUT shape của `ChildPiLineObserver` /
47
+ * worker recorder. Spec §5.3 phác callback là `StreamBridgeEvent`, nhưng không
48
+ * source constructor nào mang runId/taskId để build shape đó; bridging thuộc
49
+ * call site (child-pi.ts) nơi đủ ngữ cảnh.
50
+ */
51
+ export type WorkerEventPayload = unknown;
52
+
53
+ export interface WorkerEventSource {
54
+ /** Phân biệt nguồn — consumer chặn re-append cho "event-log" (xem header). */
55
+ readonly sourceType: "stdout" | "event-log";
56
+ /** Đăng ký callback nhận event (một source một callback — gọi lại là thay). */
57
+ onEvent(cb: (event: WorkerEventPayload) => void): void;
58
+ /** Dừng watcher/timer, detach stream. Idempotent, sync. */
59
+ close(): void;
60
+ }
61
+
62
+ /** Nhịp poll bootstrap khi chưa watch được file (mặc định 250ms). */
63
+ export const TAIL_BOOTSTRAP_POLL_MS = 250;
64
+ /**
65
+ * Backoff tối đa cho bootstrap poll khi file chưa xuất hiện (surface worker
66
+ * ghi chậm; ENOENT là đường bình thường). Không poll 250ms + log vô hạn khiến
67
+ * UI repaint liên tục — tăng dần lên tới mức này.
68
+ */
69
+ export const TAIL_BOOTSTRAP_POLL_MAX_MS = 4 * TAIL_BOOTSTRAP_POLL_MS;
70
+ /**
71
+ * Nhịp poll dự phòng KHI watcher đã gắn (spec §5.3 tail -f model). Watcher
72
+ * cho latency thấp, nhưng FSEvents (macOS) gộp/im lặng với appends liên tiếp
73
+ * vào cùng file (CI 33463597499: append thứ 2 trong cùng process không bao
74
+ * giờ tới) — poll nhẹ này là lưới an toàn cho MỌI backend watcher. statSync
75
+ * một file mỗi nhịp là no-op rẻ khi không đổi (size === offset → return).
76
+ */
77
+ export const TAIL_STEADY_POLL_MS = 250;
78
+
79
+ interface TailDeps {
80
+ /** Timer override cho test. */
81
+ setTimeoutFn?: (fn: () => void, ms: number) => unknown;
82
+ clearTimeoutFn?: (timer: unknown) => void;
83
+ }
84
+
85
+ export class EventLogTailSource implements WorkerEventSource {
86
+ readonly sourceType = "event-log" as const;
87
+
88
+ private readonly eventsPath: string;
89
+ private readonly deps: TailDeps;
90
+ private callback: ((event: WorkerEventPayload) => void) | undefined;
91
+ private watcher: fs.FSWatcher | null = null;
92
+ private bootstrapTimer: unknown;
93
+ /** Poll dự phòng steady-state (song song watcher) — xem TAIL_STEADY_POLL_MS. */
94
+ private steadyTimer: unknown;
95
+ /** Số lần bootstrap poll fail liên tiếp — cho backoff + log-once. */
96
+ private bootstrapAttempts = 0;
97
+ private closed = false;
98
+ /** Cờ chống drain-before-close đệ quy (consumer close() trong callback). */
99
+ private draining = false;
100
+ /** Byte offset đã đọc tới — tự giữ qua các lần watcher báo đổi. */
101
+ private offset = 0;
102
+ /** Nửa dòng chưa kết thúc `\n` — giữ tới lần đọc kế tiếp. */
103
+ private partial = "";
104
+
105
+ constructor(input: { eventsPath: string }, deps: TailDeps = {}) {
106
+ this.eventsPath = input.eventsPath;
107
+ this.deps = deps;
108
+ }
109
+
110
+ onEvent(cb: (event: WorkerEventPayload) => void): void {
111
+ this.callback = cb;
112
+ if (this.closed) return;
113
+ // Bắt kịp nội dung có sẵn (event landed trước khi source được dựng),
114
+ // rồi gắn watcher. File chưa có → bootstrap poll.
115
+ this.readFromOffset();
116
+ this.attachWatcher();
117
+ }
118
+
119
+ close(): void {
120
+ if (this.closed) return;
121
+ // Final catch-up (bắt được từ E2E herdr thật, 2026-08-27): pane surface
122
+ // có thể đóng rất nhanh sau worker.completed — host close ngay khi thấy
123
+ // result, TRƯỚC cả nhịp bootstrap poll 250ms kế tiếp kịp attach file
124
+ // agent-log (herdr socket nhanh; tmux chậm hơn nên thắng race). Đọc lần
125
+ // cuối đồng bộ để mọi dòng worker đã ghi vẫn tới consumer (dashboard,
126
+ // T11 controller pid) trước khi source chết.
127
+ this.drainBeforeClose();
128
+ this.closed = true;
129
+ closeWatcher(this.watcher);
130
+ this.watcher = null;
131
+ if (this.bootstrapTimer !== undefined) {
132
+ const clear = this.deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>));
133
+ clear(this.bootstrapTimer);
134
+ this.bootstrapTimer = undefined;
135
+ }
136
+ if (this.steadyTimer !== undefined) {
137
+ const clear = this.deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>));
138
+ clear(this.steadyTimer);
139
+ this.steadyTimer = undefined;
140
+ }
141
+ }
142
+
143
+ /** Drain một lần, chống đệ quy khi consumer gọi close() ngay trong callback. */
144
+ private drainBeforeClose(): void {
145
+ if (this.draining) return;
146
+ this.draining = true;
147
+ try {
148
+ this.readFromOffset();
149
+ } finally {
150
+ this.draining = false;
151
+ }
152
+ }
153
+
154
+ private attachWatcher(): void {
155
+ if (this.closed || this.watcher) return;
156
+ // `created` phải là let-null (không phải const gán kết quả) — onError có
157
+ // thể chạy ĐỒNG BỘ bên trong watchWithErrorHandler khi fs.watch throw,
158
+ // trước khi kết quả gán xong (TDZ).
159
+ let created: fs.FSWatcher | null = null;
160
+ const onError = (error?: unknown): void => {
161
+ if (created && this.watcher !== created) return; // watcher cũ lỗi muộn
162
+ this.watcher = null;
163
+ if (created) closeWatcher(created);
164
+ // Log ENOENT ĐÚNG MỘT LẦN (không spam per-poll): file agent-log chưa
165
+ // xuất hiện là đường bình thường của surface (recorder tạo event đầu);
166
+ // log lặp mỗi 250ms là nguồn UI repaint liên tục. Lần đầu vẫn warn để
167
+ // kỹ sư thấy được, sau đó chỉ backoff-im lặng.
168
+ const err = error instanceof Error ? error : new Error(String(error));
169
+ const isEnoent = (err as NodeJS.ErrnoException)?.code === "ENOENT" || /ENOENT/.test(err.message);
170
+ if (this.bootstrapAttempts === 0 || !isEnoent) {
171
+ logInternalError("event-log-tail.watch", err, `eventsPath=${this.eventsPath}`, "warn");
172
+ }
173
+ // Watcher chết giữa chừng (file bị unlink…) — bootstrap lại với backoff;
174
+ // file missing tăng dần nhịp + không log lặp.
175
+ this.scheduleBootstrap();
176
+ };
177
+ // macOS (CI 33462332100): fs.watch trên CHÍNH file miss appends — FSEvents
178
+ // gộp/im lặng với write cùng-từ-process-khác, test "2 event qua 2 append"
179
+ // timeout 4s trên macOS dù xanh Linux. Watch DIRECTORY chứa file thay thế:
180
+ // dir change events fire đáng tin trên cả FSEvents (macOS), inotify
181
+ // (Linux), ReadDirectoryChangesW (Windows). Lọc theo filename rồi đọc
182
+ // incremental — size === offset → no-op nên event khác file vô hại.
183
+ // Dir chưa tồn tại (surface mkdir lười) vẫn ENOENT → bootstrap poll giữ
184
+ // nguyên đường cũ.
185
+ const dir = path.dirname(this.eventsPath);
186
+ const base = path.basename(this.eventsPath);
187
+ created = watchWithErrorHandler(
188
+ dir,
189
+ (_eventType, filename) => {
190
+ if (typeof filename === "string" && path.basename(filename) !== base) return;
191
+ this.readFromOffset();
192
+ },
193
+ onError,
194
+ );
195
+ if (created) {
196
+ this.watcher = created;
197
+ this.bootstrapAttempts = 0; // gắn được → reset backoff/log-once
198
+ this.scheduleSteadyPoll();
199
+ } else {
200
+ this.scheduleBootstrap(); // fs.watch unsupported / file chưa tồn tại
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Lưới an toàn steady-state: watcher có thể gộp/im lặng (FSEvents macOS gộp
206
+ * appends liên tiếp — CI 33463597499). Poll nhẹ chạy song song, tự reschedule
207
+ * tới khi close(); unref để không giữ event loop sống.
208
+ */
209
+ private scheduleSteadyPoll(): void {
210
+ if (this.closed || this.steadyTimer !== undefined) return;
211
+ const set = this.deps.setTimeoutFn ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
212
+ const timer = set(() => {
213
+ this.steadyTimer = undefined;
214
+ if (this.closed) return;
215
+ this.readFromOffset();
216
+ // Watcher chết ngầm giữa chừng (không error event) → bootstrap lại.
217
+ if (!this.watcher) this.scheduleBootstrap();
218
+ else this.scheduleSteadyPoll();
219
+ }, TAIL_STEADY_POLL_MS);
220
+ this.steadyTimer = timer;
221
+ (timer as { unref?: () => void } | null)?.unref?.();
222
+ }
223
+
224
+ private scheduleBootstrap(): void {
225
+ if (this.closed || this.bootstrapTimer !== undefined || this.watcher) return;
226
+ const set = this.deps.setTimeoutFn ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
227
+ // Backoff nhịp poll khi file chưa xuất hiện (bootstrapAttempts tăng dần):
228
+ // tránh poll 250ms + repaint vô hạn khi worker surface chậm/không ghi file.
229
+ this.bootstrapAttempts += 1;
230
+ const delay = Math.min(TAIL_BOOTSTRAP_POLL_MAX_MS, TAIL_BOOTSTRAP_POLL_MS * 2 ** (this.bootstrapAttempts - 1));
231
+ const timer = set(() => {
232
+ this.bootstrapTimer = undefined;
233
+ if (this.closed) return;
234
+ this.readFromOffset();
235
+ this.attachWatcher();
236
+ if (!this.watcher) this.scheduleBootstrap();
237
+ }, delay);
238
+ this.bootstrapTimer = timer;
239
+ // Poll bootstrap không được giữ event loop sống — surface branch đã có
240
+ // waitForSurfaceExit lo việc chờ.
241
+ (timer as { unref?: () => void } | null)?.unref?.();
242
+ }
243
+
244
+ /** Đọc từ offset tới cuối file; truncate → reset offset; phát từng dòng JSON. */
245
+ private readFromOffset(): void {
246
+ if (this.closed) return;
247
+ let size: number;
248
+ try {
249
+ size = fs.statSync(this.eventsPath).size;
250
+ } catch {
251
+ // File biến mất (chưa từng có, hoặc bị dọn giữa attempt) — đọc lại từ
252
+ // 0 khi nó (tái)xuất hiện, đúng semantics truncate.
253
+ this.offset = 0;
254
+ this.partial = "";
255
+ return;
256
+ }
257
+ if (size < this.offset) {
258
+ // Shrink = truncate/replace → bỏ vị trí cũ, đọc lại toàn bộ.
259
+ this.offset = 0;
260
+ this.partial = "";
261
+ }
262
+ if (size === this.offset) return;
263
+ let chunk: string;
264
+ let bytesRead: number;
265
+ try {
266
+ const fd = fs.openSync(this.eventsPath, "r");
267
+ try {
268
+ const length = size - this.offset;
269
+ const buffer = Buffer.alloc(length);
270
+ // File có thể đổi giữa statSync và readSync (worker đang append /
271
+ // truncate): bytesRead là sự thật, stat size chỉ là dự đoán.
272
+ bytesRead = fs.readSync(fd, buffer, 0, length, this.offset);
273
+ chunk = buffer.toString("utf-8", 0, bytesRead);
274
+ } finally {
275
+ fs.closeSync(fd);
276
+ }
277
+ } catch (error) {
278
+ logInternalError(
279
+ "event-log-tail.read",
280
+ error instanceof Error ? error : new Error(String(error)),
281
+ `eventsPath=${this.eventsPath}`,
282
+ "warn",
283
+ );
284
+ return;
285
+ }
286
+ this.offset += bytesRead;
287
+
288
+ const lines = (this.partial + chunk).split("\n");
289
+ this.partial = lines.pop() ?? ""; // giữ nửa dòng cuối (chưa có \n)
290
+ for (const text of lines) {
291
+ if (!text.trim()) continue;
292
+ let parsed: { event?: unknown };
293
+ try {
294
+ parsed = JSON.parse(text) as { event?: unknown };
295
+ } catch {
296
+ // Dòng hỏng/dở — bỏ qua, không làm kẹt cả stream.
297
+ logInternalError("event-log-tail.parse", new Error("unparseable JSONL line"), `eventsPath=${this.eventsPath}`, "debug");
298
+ continue;
299
+ }
300
+ if (parsed.event === null || typeof parsed.event !== "object") continue;
301
+ try {
302
+ this.callback?.(parsed.event);
303
+ } catch (error) {
304
+ // Consumer bug không được giết watcher — event kế tiếp vẫn phải tới.
305
+ logInternalError("event-log-tail.callback", error instanceof Error ? error : new Error(String(error)), undefined, "warn");
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ /** Adapter mỏng của đường headless: child.stdout JSON lines → compacted events. */
312
+ export class StdoutJsonEventSource implements WorkerEventSource {
313
+ readonly sourceType = "stdout" as const;
314
+
315
+ private readonly stdout: Readable;
316
+ private callback: ((event: WorkerEventPayload) => void) | undefined;
317
+ private buffer = "";
318
+ private attached = false;
319
+
320
+ constructor(child: { stdout: Readable }) {
321
+ this.stdout = child.stdout;
322
+ }
323
+
324
+ onEvent(cb: (event: WorkerEventPayload) => void): void {
325
+ this.callback = cb;
326
+ if (this.attached) return;
327
+ this.attached = true;
328
+ this.stdout.on("data", this.onData);
329
+ // Stream kết thúc → flush nửa dòng còn đọng (nếu là JSON trọn vẹn).
330
+ this.stdout.once("end", this.onEnd);
331
+ }
332
+
333
+ close(): void {
334
+ this.attached = false;
335
+ this.callback = undefined;
336
+ this.stdout.removeListener("data", this.onData);
337
+ this.stdout.removeListener("end", this.onEnd);
338
+ }
339
+
340
+ private readonly onData = (chunk: Buffer | string): void => {
341
+ this.observe(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
342
+ };
343
+
344
+ private readonly onEnd = (): void => {
345
+ this.observe("\n");
346
+ };
347
+
348
+ private observe(text: string): void {
349
+ this.buffer += text;
350
+ const lines = this.buffer.split(/\r?\n/);
351
+ this.buffer = lines.pop() ?? "";
352
+ for (const line of lines) {
353
+ if (!line.trim()) continue;
354
+ let parsed: unknown;
355
+ try {
356
+ parsed = JSON.parse(line);
357
+ } catch {
358
+ continue; // dòng không phải JSON — đường headless cũng bỏ qua
359
+ }
360
+ const compacted = compactChildPiEvent(parsed);
361
+ if (compacted === undefined) continue;
362
+ try {
363
+ this.callback?.(compacted);
364
+ } catch (error) {
365
+ logInternalError(
366
+ "stdout-json-event.callback",
367
+ error instanceof Error ? error : new Error(String(error)),
368
+ undefined,
369
+ "warn",
370
+ );
371
+ }
372
+ }
373
+ }
374
+ }
@@ -33,6 +33,7 @@ import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts
33
33
  import { buildRecoveryLedger } from "./recovery/recovery-recipes.ts";
34
34
  import type { SchedulerContext } from "./scheduler-context.ts";
35
35
  import { taskGraphSnapshot } from "./scheduling/task-graph-scheduler.ts";
36
+ import { sweepLaunchScriptsAtRunEnd } from "./surface/surface-spawn.ts";
36
37
 
37
38
  function formatTaskProgress(task: TeamTaskState): string {
38
39
  return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
@@ -289,6 +290,9 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
289
290
  * @returns The final `{ manifest, tasks }` result for the run.
290
291
  */
291
292
  export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
293
+ // MuxSurface A1 (spec §5.2): dọn launch script mồ côi đúng lúc run kết thúc
294
+ // — best-effort, không ảnh hưởng kết quả run.
295
+ sweepLaunchScriptsAtRunEnd();
292
296
  const input = ctx.input;
293
297
  let tasks = ctx.tasks;
294
298
  let manifest = ctx.manifest;
@@ -119,15 +119,18 @@ export function registerLiveAgent(
119
119
  const completed = [...liveAgents.entries()].find(([, h]) => h.activity.completedAtMs > 0);
120
120
  if (completed) {
121
121
  liveAgents.delete(completed[0]);
122
+ invalidateSortedLiveAgents();
122
123
  } else {
123
124
  const oldestKey = liveAgents.keys().next().value;
124
125
  if (oldestKey !== undefined) {
125
126
  logInternalError("live-agent-manager.cap", new Error(`liveAgents at cap ${MAX_LIVE_AGENTS}; evicting oldest ${oldestKey}`));
126
127
  liveAgents.delete(oldestKey);
128
+ invalidateSortedLiveAgents();
127
129
  }
128
130
  }
129
131
  }
130
132
  liveAgents.set(input.agentId, handle);
133
+ invalidateSortedLiveAgents();
131
134
  try {
132
135
  if (eventLogFn && eventsPath)
133
136
  eventLogFn(eventsPath, {
@@ -172,6 +175,7 @@ export function updateLiveAgentStatus(agentId: string, status: CrewAgentRecord["
172
175
  if (!handle) return;
173
176
  handle.status = status;
174
177
  handle.updatedAt = new Date().toISOString();
178
+ invalidateSortedLiveAgents();
175
179
  }
176
180
 
177
181
  function safeDisposeLiveSession(handle: LiveAgentHandle): void {
@@ -198,6 +202,7 @@ export async function terminateLiveAgent(
198
202
  if (!handle) return undefined;
199
203
  handle.status = status;
200
204
  handle.updatedAt = new Date().toISOString();
205
+ invalidateSortedLiveAgents();
201
206
  try {
202
207
  if (eventLogFn && eventsPath)
203
208
  eventLogFn(eventsPath, {
@@ -220,6 +225,7 @@ export async function terminateLiveAgent(
220
225
  } finally {
221
226
  safeDisposeLiveSession(handle);
222
227
  liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
228
+ invalidateSortedLiveAgents();
223
229
  }
224
230
  return handle;
225
231
  }
@@ -260,6 +266,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
260
266
  // Terminal handle — evict after grace period
261
267
  if (age > STALE_HANDLE_MS) {
262
268
  liveAgents.delete(agentId);
269
+ invalidateSortedLiveAgents();
263
270
  safeDisposeLiveSession(handle);
264
271
  evicted++;
265
272
  }
@@ -289,6 +296,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
289
296
  const liveness = checkProcessLiveness(sessionPid);
290
297
  if (!liveness.alive) {
291
298
  liveAgents.delete(agentId);
299
+ invalidateSortedLiveAgents();
292
300
  safeDisposeLiveSession(handle);
293
301
  evicted++;
294
302
  }
@@ -297,8 +305,24 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
297
305
  return evicted;
298
306
  }
299
307
 
308
+ // PERF (2026-08-24): listLiveAgents is called 4-6x per render frame and used to
309
+ // copy + sort with ICU localeCompare every call. ISO-8601 strings are fixed-width
310
+ // and sort correctly with plain operators; the sorted array is memoized and
311
+ // invalidated at every map mutation. NOTE: callers receive the SHARED memoized
312
+ // array — never sort or mutate it in place; copy (slice/spread) first.
313
+ let sortedLiveAgents: LiveAgentHandle[] | undefined;
314
+ function invalidateSortedLiveAgents(): void {
315
+ sortedLiveAgents = undefined;
316
+ }
300
317
  export function listLiveAgents(): LiveAgentHandle[] {
301
- return [...liveAgents.values()].sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
318
+ if (!sortedLiveAgents) {
319
+ sortedLiveAgents = [...liveAgents.values()].sort((a, b) => {
320
+ const au = a.updatedAt ?? "";
321
+ const bu = b.updatedAt ?? "";
322
+ return au < bu ? 1 : au > bu ? -1 : 0;
323
+ });
324
+ }
325
+ return sortedLiveAgents;
302
326
  }
303
327
 
304
328
  export function listActiveLiveAgents(): LiveAgentHandle[] {
@@ -324,6 +348,7 @@ export async function steerLiveAgent(agentIdOrTaskId: string, message: string):
324
348
  }
325
349
  await handle.session.steer(message);
326
350
  handle.updatedAt = new Date().toISOString();
351
+ invalidateSortedLiveAgents();
327
352
  return handle;
328
353
  }
329
354
 
@@ -339,6 +364,7 @@ export async function followUpLiveAgent(agentIdOrTaskId: string, prompt: string)
339
364
  expandPromptTemplates: false,
340
365
  });
341
366
  handle.updatedAt = new Date().toISOString();
367
+ invalidateSortedLiveAgents();
342
368
  return handle;
343
369
  }
344
370
 
@@ -359,6 +385,7 @@ export async function resumeLiveAgent(agentIdOrTaskId: string, prompt: string):
359
385
  });
360
386
  handle.status = "completed";
361
387
  handle.updatedAt = new Date().toISOString();
388
+ invalidateSortedLiveAgents();
362
389
  return handle;
363
390
  }
364
391
 
@@ -377,6 +404,7 @@ export function trackLiveAgentToolStart(agentIdOrTaskId: string, toolName: strin
377
404
  handle.activity.activeTools.set(toolName, toolName);
378
405
  handle.activity.toolUses++;
379
406
  handle.updatedAt = new Date().toISOString();
407
+ invalidateSortedLiveAgents();
380
408
  }
381
409
 
382
410
  /** G2: Track tool end for a live agent. */
@@ -394,6 +422,7 @@ export function trackLiveAgentTurnEnd(agentIdOrTaskId: string, compaction = fals
394
422
  if (compaction) handle.activity.compactionCount++;
395
423
  handle.activity.activeTools.clear();
396
424
  handle.updatedAt = new Date().toISOString();
425
+ invalidateSortedLiveAgents();
397
426
  }
398
427
 
399
428
  /** G2: Track assistant response text. */
@@ -413,6 +442,7 @@ export function markLiveAgentCompleted(agentIdOrTaskId: string): void {
413
442
 
414
443
  export function clearLiveAgentsForTest(): void {
415
444
  liveAgents.clear();
445
+ invalidateSortedLiveAgents();
416
446
  }
417
447
 
418
448
  /** Phase 7/G4: Send an IRC message to a specific live agent (DM).
@@ -427,6 +457,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
427
457
  }
428
458
  handle.pendingMessages.push(message);
429
459
  handle.updatedAt = new Date().toISOString();
460
+ invalidateSortedLiveAgents();
430
461
  // G4: Try non-blocking delivery via sendCustomMessage
431
462
  const session = handle.session as Record<string, unknown>;
432
463
  if (typeof session.sendCustomMessage === "function") {
@@ -467,6 +498,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
467
498
  }
468
499
  handle.pendingMessages.push(message);
469
500
  handle.updatedAt = new Date().toISOString();
501
+ invalidateSortedLiveAgents();
470
502
  // G4: Try non-blocking delivery
471
503
  const session = handle.session as Record<string, unknown>;
472
504
  if (typeof session.sendCustomMessage === "function") {
@@ -600,6 +632,7 @@ export async function respondAsBackground(
600
632
  error: `Target '${targetAgentId}' has no message channel.`,
601
633
  };
602
634
  handle.updatedAt = new Date().toISOString();
635
+ invalidateSortedLiveAgents();
603
636
 
604
637
  if (!awaitReply) return { ok: true, corrId };
605
638
 
@@ -19,6 +19,16 @@ export function subscribeLiveControlRealtime(listener: Listener): () => void {
19
19
  return () => listeners.delete(listener);
20
20
  }
21
21
 
22
+ /**
23
+ * Task 26 (2026-08-24): poll-interval gate for live-session control polling.
24
+ * True while at least one realtime listener is registered — in that mode
25
+ * in-process control requests are delivered immediately and the file poll is
26
+ * only a cross-process fallback, so the poller can run slower.
27
+ */
28
+ export function hasLiveControlRealtimeListeners(): boolean {
29
+ return listeners.size > 0;
30
+ }
31
+
22
32
  export function liveControlRealtimeMessage(request: LiveAgentControlRequest): LiveControlRealtimeMessage {
23
33
  return { type: "live-control", version: 1, request };
24
34
  }