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,546 @@
1
+ /**
2
+ * herdr SurfaceProvider (spec §4) — Socket API NDJSON client
3
+ *
4
+ * Wire format đối chiếu trực tiếp với server herdr 0.8.2 (protocol 20) qua
5
+ * `herdr api schema --json` + probe trên socket thật (2026-08-26):
6
+ * - MỖI REQUEST MỘT CONNECTION: connect → 1 dòng request `{"id","method","params"}`
7
+ * → 1 dòng response cùng id → server tự đóng (request thứ hai trên cùng
8
+ * connection nhận broken pipe). Verified thực nghiệm.
9
+ * - Subscription là connection dài hạn riêng: ack `subscription_started` rồi
10
+ * mỗi dòng sau là event pushed với envelope `{"event":"pane_closed","data":{...}}`
11
+ * — event kind DÙNG UNDERSCORE và KHÔNG có id (khác request/response).
12
+ * Subscribe cả `pane.closed` (đóng qua API) LẪN `pane.exited` (process
13
+ * exit tự nhiên) — herdr 0.8.2 KHÔNG push pane.closed cho exit tự nhiên.
14
+ * - Socket path resolution theo docs herdr.dev/docs/socket-api:
15
+ * HERDR_SOCKET_PATH → HERDR_SESSION (sessions/<name>/herdr.sock) → default.
16
+ *
17
+ * onExit: MỘT subscription connection chung cho cả provider (lazy — mở khi
18
+ * handle đầu tiên đăng ký onExit), subscribe `pane.closed` server-wide rồi
19
+ * filter theo pane_id client-side (schema không hỗ trợ filter pane_id cho
20
+ * pane.closed). Subscription socket EOF (server chết/restart) → "mux-dead"
21
+ * cho mọi handle còn sống.
22
+ *
23
+ * closeSurface A1: herdr không có signal-theo-pid trên socket API —
24
+ * `pane.close` để herdr tự terminate cả cây process trong pane, nên graceful
25
+ * và force cùng đường (khác tmux provider). TODO(A2): kill theo pid worker
26
+ * từ manifest (pane.process_info) trước khi pane.close cho graceful thật.
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as net from "node:net";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+
34
+ import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
35
+ import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
36
+
37
+ /**
38
+ * Socket abstraction injectable. Quy ước EOF/error: onLine được gọi đúng một
39
+ * lần với chuỗi rỗng khi server đóng connection (đối chiếu NDJSON — dòng
40
+ * rỗng không bao giờ là message hợp lệ).
41
+ */
42
+ export interface HerdrSocket {
43
+ write(line: string): void;
44
+ onLine(cb: (line: string) => void): void;
45
+ close(): void;
46
+ }
47
+
48
+ /** Dependencies injectable — mọi I/O đều thay được để unit test không chạm server thật. */
49
+ export interface HerdrProviderDeps {
50
+ /** Mở connection tới socket path. Throw khi không connect được. */
51
+ connect?: (path: string) => HerdrSocket;
52
+ /** Nguồn HERDR_SOCKET_PATH / HERDR_SESSION (default process.env). */
53
+ env?: NodeJS.ProcessEnv;
54
+ }
55
+
56
+ /** herdr socket path theo docs: HERDR_SOCKET_PATH → HERDR_SESSION → default. */
57
+ export function herdrSocketPath(env: NodeJS.ProcessEnv): string {
58
+ if (env.HERDR_SOCKET_PATH) return env.HERDR_SOCKET_PATH;
59
+ if (env.HERDR_SESSION) {
60
+ return path.join(os.homedir(), ".config", "herdr", "sessions", env.HERDR_SESSION, "herdr.sock");
61
+ }
62
+ return path.join(os.homedir(), ".config", "herdr", "herdr.sock");
63
+ }
64
+
65
+ /** Default connect: unix socket NDJSON. accessSync fail-fast cho detect sync. */
66
+ function defaultConnect(socketPath: string): HerdrSocket {
67
+ fs.accessSync(socketPath);
68
+ // KHÔNG unref: request socket tự đóng sau response (vài ms), còn
69
+ // subscription socket PHẢI giữ event loop sống khi còn pane cần theo dõi —
70
+ // unref khiến process exit giữa chừng với promise pending.
71
+ const socket = net.createConnection({ path: socketPath });
72
+ let lineCb: ((line: string) => void) | null = null;
73
+ let buffer = "";
74
+ let ended = false;
75
+ socket.setEncoding("utf8");
76
+ socket.on("data", (chunk: string) => {
77
+ buffer += chunk;
78
+ let idx = buffer.indexOf("\n");
79
+ while (idx !== -1) {
80
+ const line = buffer.slice(0, idx);
81
+ buffer = buffer.slice(idx + 1);
82
+ if (line.trim()) lineCb?.(line);
83
+ idx = buffer.indexOf("\n");
84
+ }
85
+ });
86
+ const onEnd = (): void => {
87
+ if (ended) return;
88
+ ended = true;
89
+ lineCb?.("");
90
+ lineCb = null;
91
+ };
92
+ socket.on("close", onEnd);
93
+ socket.on("error", onEnd);
94
+ return {
95
+ write(line) {
96
+ socket.write(`${line}\n`);
97
+ },
98
+ onLine(cb) {
99
+ lineCb = cb;
100
+ },
101
+ close() {
102
+ socket.destroy();
103
+ },
104
+ };
105
+ }
106
+
107
+ /** Một dòng response/error từ server (đã parse). */
108
+ interface WireResponse {
109
+ id?: string;
110
+ result?: { type?: string } & Record<string, unknown>;
111
+ error?: { code?: string; message?: string };
112
+ }
113
+
114
+ /** Một dòng event pushed trên subscription connection (đã parse). */
115
+ interface WireEvent {
116
+ event?: string;
117
+ data?: { pane_id?: string };
118
+ }
119
+
120
+ /** Watcher theo pane id — callback list + trạng thái exit cho replay. */
121
+ interface PaneWatcher {
122
+ callbacks: Array<(reason: SurfaceExitReason) => void>;
123
+ exited: boolean;
124
+ reason?: SurfaceExitReason;
125
+ }
126
+
127
+ export function createHerdrProvider(deps: HerdrProviderDeps = {}): SurfaceProvider {
128
+ const env = deps.env ?? process.env;
129
+ const connect = deps.connect ?? defaultConnect;
130
+
131
+ const watchers = new Map<string, PaneWatcher>();
132
+ let reqSeq = 0;
133
+ let subscription: HerdrSocket | null = null;
134
+ let subscriptionCb: ((line: string) => void) | null = null;
135
+
136
+ // Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → {tabIds
137
+ // (MỌI tab của run), rootPaneId (tab đang nhận split), paneCount}. Tab mở
138
+ // khi run spawn worker đầu; KHÔNG đóng khi worker xong — chỉ đóng khi run
139
+ // end (Task 5 gọi closeTab). Giữ array vì run dài có thể vượt
140
+ // MAX_PANES_PER_TAB và mở tab kế — closeTab phải dọn cả tab cũ.
141
+ const tabMap = new Map<string, { tabIds: string[]; rootPaneId: string; paneCount: number }>();
142
+ // Race Task 4 (review Task 3): serialize createSurface per tabKey — giữa lúc
143
+ // đọc tabMap và deferred-commit có 2 await (tab.create + pane.split) nên
144
+ // caller spawn worker song song sẽ đua nhau. Xem createSurface.
145
+ const tabInFlight = new Map<string, Promise<unknown>>();
146
+
147
+ function activeWatchers(): number {
148
+ let active = 0;
149
+ for (const watcher of watchers.values()) if (!watcher.exited) active += 1;
150
+ return active;
151
+ }
152
+
153
+ /** Bắn reason MỘT lần cho mọi callback của pane (copy list — cb có thể dispose). */
154
+ function fire(paneId: string, reason: SurfaceExitReason): void {
155
+ const watcher = watchers.get(paneId);
156
+ if (!watcher || watcher.exited) return;
157
+ watcher.exited = true;
158
+ watcher.reason = reason;
159
+ for (const cb of [...watcher.callbacks]) cb(reason);
160
+ maybeCloseSubscription();
161
+ }
162
+
163
+ function maybeCloseSubscription(): void {
164
+ if (!subscription || activeWatchers() > 0) return;
165
+ subscription.close();
166
+ subscription = null;
167
+ subscriptionCb = null;
168
+ }
169
+
170
+ /**
171
+ * Gửi một request trên connection riêng (verified: server đóng sau response).
172
+ * Đóng socket ngay khi có kết quả — id tăng dần req-N toàn provider.
173
+ */
174
+ function call<T>(method: string, params: Record<string, unknown>): Promise<T> {
175
+ reqSeq += 1;
176
+ const id = `req-${reqSeq}`;
177
+ return new Promise<T>((resolve, reject) => {
178
+ let socket: HerdrSocket;
179
+ try {
180
+ socket = connect(herdrSocketPath(env));
181
+ } catch (err) {
182
+ reject(new Error(`herdr socket unavailable: ${(err as Error).message}`));
183
+ return;
184
+ }
185
+ let settled = false;
186
+ socket.onLine((line) => {
187
+ if (settled) return;
188
+ if (!line) {
189
+ // EOF trước response — server chết giữa chừng.
190
+ settled = true;
191
+ socket.close();
192
+ reject(new Error(`herdr socket closed before response to ${id} (${method})`));
193
+ return;
194
+ }
195
+ let msg: WireResponse;
196
+ try {
197
+ msg = JSON.parse(line) as WireResponse;
198
+ } catch {
199
+ return; // dòng lệch format — bỏ qua, chờ response thật
200
+ }
201
+ if (msg.id !== id) return;
202
+ settled = true;
203
+ socket.close();
204
+ if (msg.error) {
205
+ reject(new Error(`${msg.error.code ?? "herdr_error"}: ${msg.error.message ?? "unknown error"}`));
206
+ return;
207
+ }
208
+ resolve(msg.result as T);
209
+ });
210
+ // Wire là newline-JSON nhưng KHÔNG tự thêm \n ở đây: defaultConnect's
211
+ // write() wrapper đã nối `\n` (verified live herdr 0.8.2, 2026-08-27:
212
+ // frame `\n\n` khiến server ĐÓNG subscription connection — empty line
213
+ // bị coi là malformed → mọi watcher thành mux-dead).
214
+ socket.write(JSON.stringify({ id, method, params }));
215
+ });
216
+ }
217
+
218
+ /** Dispatch một dòng trên subscription connection: event hoặc EOF. */
219
+ function onSubscriptionLine(line: string): void {
220
+ if (!line) {
221
+ // Server đóng subscription socket — mọi handle còn sống thành mux-dead.
222
+ subscription = null;
223
+ subscriptionCb = null;
224
+ for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
225
+ return;
226
+ }
227
+ let msg: WireEvent;
228
+ try {
229
+ msg = JSON.parse(line) as WireEvent;
230
+ } catch {
231
+ return;
232
+ }
233
+ // CẢ HAI loại event đều là "pane biến mất" cho pi-crew (verified live
234
+ // herdr 0.8.2, 2026-08-27): `pane_closed` chỉ bắn khi đóng qua API
235
+ // pane.close; process exit tự nhiên (worker xong việc → shell `exit`)
236
+ // chỉ bắn `pane_exited` — thiếu nó thì mọi worker hoàn thành bình thường
237
+ // treo host tới response deadline 600s (bắt được từ E2E herdr thật).
238
+ if (msg.event !== "pane_closed" && msg.event !== "pane_exited") return;
239
+ const paneId = msg.data?.pane_id;
240
+ if (typeof paneId === "string" && watchers.has(paneId)) fire(paneId, "pane-closed");
241
+ }
242
+
243
+ /**
244
+ * Mở (một lần) subscription connection khi handle đầu tiên cần onExit.
245
+ * Lazy như ensureTimer của tmux provider — không giữ socket khi không
246
+ * theo dõi pane nào. Subscribe fail → mux-dead mọi watcher sống.
247
+ * Note: pane đóng giữa split xong và onExit đầu tiên sẽ mất event —
248
+ * caller đăng ký onExit ngay sau createSurface nên gap chỉ tính ms.
249
+ */
250
+ function ensureSubscription(): void {
251
+ if (subscription) return;
252
+ reqSeq += 1;
253
+ const id = `req-${reqSeq}`;
254
+ let socket: HerdrSocket;
255
+ try {
256
+ socket = connect(herdrSocketPath(env));
257
+ } catch {
258
+ for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
259
+ return;
260
+ }
261
+ subscription = socket;
262
+ subscriptionCb = onSubscriptionLine;
263
+ socket.onLine((line) => subscriptionCb?.(line));
264
+ // Frame nối \n bởi defaultConnect's write() wrapper — KHÔNG thêm \n ở
265
+ // đây (frame `\n\n` → server đóng subscription, xem comment trong call()).
266
+ // Subscribe CẢ pane.closed LẪN pane.exited — xem onSubscriptionLine.
267
+ socket.write(
268
+ JSON.stringify({
269
+ id,
270
+ method: "events.subscribe",
271
+ params: { subscriptions: [{ type: "pane.closed" }, { type: "pane.exited" }] },
272
+ }),
273
+ );
274
+ // Ack subscription_started cũng đi qua onSubscriptionLine — JSON hợp lệ
275
+ // nhưng thiếu envelope event nên bị bỏ qua một cách vô hại.
276
+ }
277
+
278
+ function makeHandle(paneId: string, tabId?: string): SurfaceHandle {
279
+ return {
280
+ id: paneId,
281
+ kind: "herdr",
282
+ ...(tabId ? { tabId } : {}),
283
+ onExit(cb) {
284
+ let watcher = watchers.get(paneId);
285
+ if (!watcher) {
286
+ watcher = { callbacks: [], exited: false };
287
+ watchers.set(paneId, watcher);
288
+ }
289
+ watcher.callbacks.push(cb);
290
+ // Đăng ký sau exit → replay reason ngay để không mất event.
291
+ if (watcher.exited) {
292
+ cb(watcher.reason as SurfaceExitReason);
293
+ return;
294
+ }
295
+ ensureSubscription();
296
+ },
297
+ dispose() {
298
+ const watcher = watchers.get(paneId);
299
+ if (!watcher) return;
300
+ // Host chủ động dispose khi pane còn sống → "detached" cho listener.
301
+ if (!watcher.exited) fire(paneId, "detached");
302
+ watchers.delete(paneId);
303
+ maybeCloseSubscription();
304
+ },
305
+ };
306
+ }
307
+
308
+ function assertHerdrHandle(handle: SurfaceHandle): void {
309
+ if (handle.kind !== "herdr") {
310
+ throw new Error(`Expected a herdr handle, got kind "${handle.kind}" (id ${handle.id})`);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Thân spawn chung sau khi biết pane cha + hướng: pane.split → commit
316
+ * tab-map (nếu có) → rename (cosmetic) → command → handle.
317
+ */
318
+ async function splitAndBoot(
319
+ opts: SurfaceSpawnOpts,
320
+ parentPaneId: string,
321
+ direction: "down" | "right",
322
+ commitTabPane: (() => void) | null,
323
+ tabId?: string,
324
+ ): Promise<SurfaceHandle> {
325
+ const split = await call<{ pane?: { pane_id?: string } }>("pane.split", {
326
+ direction,
327
+ target_pane_id: parentPaneId,
328
+ cwd: opts.cwd,
329
+ focus: false,
330
+ });
331
+ const paneId = split.pane?.pane_id;
332
+ if (!paneId) throw new Error("pane.split returned no pane_id");
333
+ commitTabPane?.();
334
+ if (opts.title) {
335
+ try {
336
+ await call("pane.rename", { pane_id: paneId, label: opts.title });
337
+ } catch {
338
+ // Title là cosmetic — pane vẫn dùng được.
339
+ }
340
+ }
341
+ // Command đã build sẵn ("bash <script-path>") — gửi literal + newline.
342
+ // Commandless tạo pane là hợp lệ (spec §13.1) — bỏ qua khi không có.
343
+ if (opts.command !== undefined) {
344
+ await call("pane.send_text", { pane_id: paneId, text: `${opts.command}\n` });
345
+ }
346
+ return makeHandle(paneId, tabId);
347
+ }
348
+
349
+ /**
350
+ * Nhánh tabKey (spec tab-layout): mọi worker của cùng run chia 1 tab,
351
+ * split từ root pane của tab theo hướng dọc/ngang xen kẽ (spec tab-layout
352
+ * §4). CHỈ chạy trong lock per-tabKey (tabInFlight) — giữa lúc đọc tabMap
353
+ * và deferred-commit (sau split thành công) có await nên caller spawn
354
+ * song song sẽ đua nhau mà không lock.
355
+ */
356
+ async function doTabSpawn(opts: SurfaceSpawnOpts, tabKey: string): Promise<SurfaceHandle> {
357
+ const existing = tabMap.get(tabKey);
358
+ // Tab-map chỉ commit sau khi pane.split THÀNH CÔNG — nếu split fail,
359
+ // paneCount không đếm pane không tồn tại (luân phiên không lệch bước ở
360
+ // lần retry kế tiếp) — cùng pattern tabWindows của tmux provider.
361
+ if (existing && existing.paneCount < MAX_PANES_PER_TAB) {
362
+ const paneIndexInTab = existing.paneCount;
363
+ const currentTabId = existing.tabIds[existing.tabIds.length - 1] as string;
364
+ return splitAndBoot(
365
+ opts,
366
+ existing.rootPaneId,
367
+ splitDirectionFor(paneIndexInTab),
368
+ () => {
369
+ existing.paneCount = paneIndexInTab + 1;
370
+ },
371
+ currentTabId,
372
+ );
373
+ }
374
+ // Tab mới cho run (hoặc tab cũ đã đầy MAX_PANES_PER_TAB pane).
375
+ // Wire herdr thật (verified live 2026-08-27): `tab.create` params
376
+ // {label, workspace_id?} → result.tab.tab_id + result.root_pane.pane_id.
377
+ const created = await call<{ tab?: { tab_id?: string }; root_pane?: { pane_id?: string } }>("tab.create", {
378
+ label: opts.title ?? tabKey,
379
+ ...(env.HERDR_WORKSPACE_ID ? { workspace_id: env.HERDR_WORKSPACE_ID } : {}),
380
+ });
381
+ const tabId = created.tab?.tab_id;
382
+ const rootPaneId = created.root_pane?.pane_id;
383
+ if (!tabId || !rootPaneId) throw new Error("tab.create returned no tab_id/root_pane");
384
+ const priorTabIds = existing?.tabIds ?? [];
385
+ return splitAndBoot(
386
+ opts,
387
+ rootPaneId,
388
+ splitDirectionFor(0),
389
+ () => {
390
+ tabMap.set(tabKey, { tabIds: [...priorTabIds, tabId], rootPaneId, paneCount: 1 });
391
+ },
392
+ tabId,
393
+ );
394
+ }
395
+
396
+ /** Đường legacy (spawn ngoài run, KHÔNG tabKey) — giữ nguyên như trước tab-layout. */
397
+ async function spawnFromCallerPane(opts: SurfaceSpawnOpts): Promise<SurfaceHandle> {
398
+ // Pane cha = pane của PROCESS đang gọi, lấy từ env HERDR_PANE_ID (đặt bởi
399
+ // herdr server khi spawn process trong pane — tương đương $TMUX_PANE của
400
+ // tmux). Fallback pane.current chỉ dùng khi env thiếu:
401
+ // - Verify live (2026-08-27): `herdr pane current` chạy trong pane w2:p57
402
+ // (KHÔNG focus) vẫn trả w2:p48 (focus hiện tại của server) — tức
403
+ // pane.current là FOCUS pane, KHÔNG phải pane của caller. Có truyền
404
+ // caller_pane_id thì server 0.8.2 vẫn không theo.
405
+ // - Env HERDR_PANE_ID là nguồn chính xác duy nhất cho "pane của process".
406
+ const envPaneId = env.HERDR_PANE_ID;
407
+ let parentPaneId: string | undefined;
408
+ if (envPaneId) parentPaneId = envPaneId;
409
+ else {
410
+ const current = await call<{ pane?: { pane_id?: string } }>("pane.current", {});
411
+ parentPaneId = current.pane?.pane_id;
412
+ }
413
+ if (!parentPaneId) {
414
+ throw new Error("no parent pane — HERDR_PANE_ID unset, no tabKey, and pane.current returned no pane_id");
415
+ }
416
+ return splitAndBoot(opts, parentPaneId, "right", null);
417
+ }
418
+
419
+ return {
420
+ kind: "herdr",
421
+
422
+ detect(): SurfaceDetection {
423
+ // Cheap probe: connect được tới socket là ok. Full liveness ping
424
+ // (ping + timeout) là trách nhiệm resolveSurface (T2, pingSocketSync)
425
+ // — SurfaceDetection sync nên provider không đợi response ở đây.
426
+ try {
427
+ const socket = connect(herdrSocketPath(env));
428
+ socket.close();
429
+ return { ok: true, kind: "herdr" };
430
+ } catch (err) {
431
+ return { ok: false, reason: `herdr socket unavailable: ${(err as Error).message}` };
432
+ }
433
+ },
434
+
435
+ async createSurface(_name: string, opts: SurfaceSpawnOpts): Promise<SurfaceHandle> {
436
+ if (!opts.tabKey) {
437
+ // Không lock cho đường legacy — không đụng tabMap nên không race.
438
+ return spawnFromCallerPane(opts);
439
+ }
440
+ // Race Task 4 (review Task 3): team-run spawn worker SONG SONG — 2
441
+ // createSurface cùng tabKey sẽ cùng đọc tabMap trước khi cái nào commit
442
+ // → 2 tab.create (1 tab mồ côi) hoặc cùng paneIndexInTab (under-count,
443
+ // luân phiên lệch bước). Serialize per tabKey bằng promise chain. Entry
444
+ // KHÔNG bị clear: promise đã settled thì `.then` kế chạy ngay ở
445
+ // microtask, entry nhỏ và provider sống bằng run lifecycle. Chain lưu
446
+ // bản .catch(() => {}) để MỘT lần fail không làm chết các lần sau.
447
+ // tmux miễn nhiễm race này vì execFileSync sync.
448
+ const tabKey = opts.tabKey;
449
+ const prev = tabInFlight.get(tabKey) ?? Promise.resolve();
450
+ const run = prev.then(() => doTabSpawn(opts, tabKey));
451
+ // Chain lưu bản đã-nuốt-lỗi: `.then` kế chỉ cần biết promise đã settled
452
+ // (kể cả reject); lỗi thật đã về caller qua `run`.
453
+ tabInFlight.set(
454
+ tabKey,
455
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: nuốt lỗi CÓ Ý ĐỊNH — chain cần promise không-bao-giờ-reject
456
+ run.catch(() => {}),
457
+ );
458
+ return await run;
459
+ },
460
+
461
+ async sendCommand(handle, text) {
462
+ assertHerdrHandle(handle);
463
+ await call("pane.send_text", { pane_id: handle.id, text: `${text}\n` });
464
+ },
465
+
466
+ attach(id: string): SurfaceHandle | null {
467
+ // SurfaceProvider.attach là SYNC nên không round-trip socket kiểm tra
468
+ // pane tồn tại được (A1 ghi chú này từ đầu). Trước đây return null —
469
+ // hệ quả: doctor xếp MỌI orphan herdr là "gone" và không bao giờ đóng
470
+ // pane (bắt được từ E2E herdr 2026-08-27). Giờ trả handle OPTIMISTIC:
471
+ // caller xác minh aliveness qua readScreen (async, doctor đã làm),
472
+ // còn closeSurface lên pane không tồn tại đã idempotent
473
+ // (pane_not_found → coi như đã đóng, không throw).
474
+ return makeHandle(id);
475
+ },
476
+
477
+ async readScreen(handle: SurfaceHandle, lines = 50): Promise<string> {
478
+ assertHerdrHandle(handle);
479
+ // source "visible" — verified live trên herdr 0.8.2: "recent"/"recent_unwrapped"
480
+ // trả text rỗng kể cả trên pane đang bận, còn "visible" trả đúng nội
481
+ // dung màn hình (đúng ngữ nghĩa "current screen" của interface).
482
+ const result = await call<{ read?: { text?: string } }>("pane.read", {
483
+ pane_id: handle.id,
484
+ source: "visible",
485
+ lines: Math.max(1, lines),
486
+ });
487
+ return result.read?.text ?? "";
488
+ },
489
+
490
+ async closeSurface(handle: SurfaceHandle, _opts?: { force?: boolean }): Promise<void> {
491
+ assertHerdrHandle(handle);
492
+ // A1: graceful lẫn force đều pane.close — herdr terminate cả cây
493
+ // process trong pane (xem header TODO(A2) cho graceful theo pid).
494
+ try {
495
+ await call("pane.close", { pane_id: handle.id });
496
+ } catch (err) {
497
+ // Pane đã mất từ trước → mục tiêu đạt được, idempotent.
498
+ if ((err as Error).message.includes("pane_not_found")) return;
499
+ throw err;
500
+ }
501
+ },
502
+
503
+ /**
504
+ * Task 5 (spec tab-layout §5): run end → đóng MỌI tab của run theo map
505
+ * nội bộ (run dài >8 pane mở tab kế — cả hai đều phải chết). tab đã mất
506
+ * (pane exit tự nhiên làm server dọn tab) → tab_not_found → idempotent;
507
+ * lỗi khác vẫn ném về closeTabForRun (best-effort log ở caller). Dọn cả
508
+ * lock tabInFlight (minor deferred từ review Task 4) — run đã kết thúc
509
+ * thì không còn spawn nào cùng tabKey.
510
+ */
511
+ async closeTab(tabKey: string): Promise<void> {
512
+ const entry = tabMap.get(tabKey);
513
+ if (!entry) return;
514
+ tabMap.delete(tabKey);
515
+ tabInFlight.delete(tabKey);
516
+ let firstError: unknown = null;
517
+ for (const tabId of entry.tabIds) {
518
+ try {
519
+ await call("tab.close", { tab_id: tabId });
520
+ } catch (err) {
521
+ if ((err as Error).message.includes("tab_not_found")) continue; // idempotent
522
+ if (firstError === null) firstError = err;
523
+ }
524
+ }
525
+ if (firstError !== null) throw firstError;
526
+ },
527
+
528
+ /**
529
+ * Task 6 (doctor cleanup-by-id): đóng MỘT tab theo id đọc từ
530
+ * manifest.surface.tabs — doctor chạy ở process khác host đã spawn nên
531
+ * tabMap ở đó trống (closeTab no-op). Wire 0.8.2 không có lệnh đọc tab
532
+ * đã verify (tab.get chưa probe) nên liveness lấy từ CHÍNH tab.close:
533
+ * `tab_not_found` = server xác nhận tab đã mất → "gone", thành công →
534
+ * "closed" — idempotent như closeTab ở trên, lỗi thật vẫn ném về doctor.
535
+ */
536
+ async closeTabById(tabId: string): Promise<"closed" | "gone"> {
537
+ try {
538
+ await call("tab.close", { tab_id: tabId });
539
+ return "closed";
540
+ } catch (err) {
541
+ if ((err as Error).message.includes("tab_not_found")) return "gone";
542
+ throw err;
543
+ }
544
+ },
545
+ };
546
+ }