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,172 @@
1
+ /**
2
+ * launch-script builder — boot script cho tier-1 surface worker (spec §5.2)
3
+ *
4
+ * Host không thể spawn TUI worker bằng argv (Pi từ chối flag lạ và `--mode
5
+ * json -p` là chế độ headless), nên pane chạy `bash <script>`: script export
6
+ * đầy đủ env worker, cd vào cwd, chạy dòng lệnh pi TUI, rồi tự xóa bằng
7
+ * `rm -f -- "$0"`. Script là file one-shot tuổi thọ vài giây — TTL registry
8
+ * dọn script mồ côi (worker chưa kịp chạy đã chết) sau 60s.
9
+ *
10
+ * Depth guard lớp 2: builder throw khi NGƯỜI GỌI đang lồng (callerEnv có
11
+ * PI_CREW_DEPTH > 0; không truyền callerEnv thì fallback đọc env input —
12
+ * hành vi cũ). Lớp 1 là resolveSurface trả null (spec §3) — lớp 2 bảo vệ
13
+ * đường gọi trực tiếp builder mà bỏ qua resolveSurface.
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+
19
+ import { atomicWriteFile } from "../../state/atomic-write.ts";
20
+ import { currentCrewDepth } from "../model/pi-args.ts";
21
+
22
+ /**
23
+ * Thrown khi buildLaunchScript nhận env có PI_CREW_DEPTH > 0 — surface panes
24
+ * là tier-1 only, không pane-in-pane (spec §3/§5.2).
25
+ */
26
+ export class SurfaceDepthGuardError extends Error {
27
+ constructor(depth: number) {
28
+ super(`Refusing to build surface launch script at PI_CREW_DEPTH=${depth}: surface panes are tier-1 only`);
29
+ this.name = "SurfaceDepthGuardError";
30
+ }
31
+ }
32
+
33
+ /** Thrown khi taskId dùng để đặt tên file script không an toàn cho path. */
34
+ export class SurfaceTaskIdError extends Error {
35
+ constructor(taskId: string) {
36
+ super(`Refusing to build surface launch script: unsafe taskId ${JSON.stringify(taskId)} — must not contain "/", "\\", NUL or ".."`);
37
+ this.name = "SurfaceTaskIdError";
38
+ }
39
+ }
40
+
41
+ /**
42
+ * taskId được nối thẳng vào tên file (`pi-crew-launch-{taskId}-{pid}.sh`) —
43
+ * `/`, `\`, NUL hay `..` trong đó là path traversal ghi file ra ngoài baseDir.
44
+ * Follow-up bắt buộc từ review Task 5.
45
+ */
46
+ function assertSafeTaskId(taskId: string): void {
47
+ if (!taskId || taskId.includes("/") || taskId.includes("\\") || taskId.includes("\0") || taskId.includes("..")) {
48
+ throw new SurfaceTaskIdError(taskId);
49
+ }
50
+ }
51
+
52
+ export interface BuildLaunchScriptInput {
53
+ taskId: string;
54
+ /** Env worker cần có trong pane (broker, steering, surface, parent-guard). */
55
+ env: Record<string, string>;
56
+ /** Dòng lệnh pi TUI (đã build, không `--mode json -p`). */
57
+ command: string;
58
+ /** Working directory của worker (được cd với shell-escape). */
59
+ cwd: string;
60
+ /** Thư mục chứa script — dùng getPiTempBase() từ pi-args.ts. */
61
+ baseDir: string;
62
+ /**
63
+ * Env của NGƯỜI GỌI builder — nguồn duy nhất cho depth guard lớp 2. Khi
64
+ * được truyền, guard KHÔNG còn đọc env export nữa vì script worker hợp lệ
65
+ * mang PI_CREW_DEPTH=<caller+1> (parity với spawn headless — child-pi-spawn
66
+ * luôn set depth con = cha + 1); chỉ người GỌI lồng mới bị chặn.
67
+ * Không truyền → hành vi cũ (đọc env input) giữ nguyên cho caller đã có.
68
+ */
69
+ callerEnv?: NodeJS.ProcessEnv;
70
+ }
71
+
72
+ /** TTL cho launch script (spec §5.2): sweep xóa script cũ hơn 60s. */
73
+ export const LAUNCH_SCRIPT_TTL_MS = 60_000;
74
+
75
+ /**
76
+ * Module-level TTL registry — path script → createdAt (epoch ms). Sweep trước
77
+ * mỗi spawn và khi run kết thúc (caller truyền registry này vào
78
+ * sweepLaunchScripts). Export cho test và cho caller Task 6.
79
+ */
80
+ export const launchScriptRegistry = new Map<string, number>();
81
+
82
+ /**
83
+ * Shell single-quote escape: bọc giá trị trong '...' và biến nháy đơn thành
84
+ * '\'' — bên trong single-quote KHÔNG có expansion, nên $(), backtick, $VAR
85
+ * đều nguyên vẹn.
86
+ */
87
+ export function shellEscape(value: string): string {
88
+ return `'${value.replaceAll("'", "'\\''")}'`;
89
+ }
90
+
91
+ /**
92
+ * Viết launch script ra {baseDir}/pi-crew-launch-{taskId}-{pid}.sh (mode 0600,
93
+ * symlink-safe qua atomicWriteFile), đăng ký vào TTL registry, trả path.
94
+ *
95
+ * Mọi giá trị env và cwd đều qua shellEscape — env chứa token broker, đường
96
+ * dẫn, và dữ liệu từ task không tin cậy được để raw vào file bash.
97
+ */
98
+ export function buildLaunchScript(input: BuildLaunchScriptInput): string {
99
+ assertSafeTaskId(input.taskId);
100
+ const depth = currentCrewDepth(input.callerEnv ?? input.env);
101
+ if (depth > 0) throw new SurfaceDepthGuardError(depth);
102
+
103
+ // path.resolve (không phải path.join): script path phải ABSOLUTE — `rm -f
104
+ // -- "$0"` chạy trong pane SAU dòng `cd <cwd>`, nên $0 relative sẽ trỏ sai
105
+ // chỗ và self-delete thành no-op (T7 obs, fixed ở T14).
106
+ const scriptPath = path.resolve(input.baseDir, `pi-crew-launch-${input.taskId}-${process.pid}.sh`);
107
+ const lines: string[] = ["#!/bin/bash"];
108
+ for (const [key, value] of Object.entries(input.env)) {
109
+ lines.push(`export ${key}=${shellEscape(value)}`);
110
+ }
111
+ lines.push(`cd ${shellEscape(input.cwd)}`);
112
+ // Self-delete SỚM (fix round 1/F3): script chứa token broker trong env —
113
+ // thu hẹp secret-on-disk window xuống vài ms thay vì hết life worker. An toàn
114
+ // vì bash đã mở fd lên file: tiến trình đang chạy đọc tiếp bằng fd dù đường
115
+ // dẫn biến mất. rm cuối giữ lại như idempotent backstop.
116
+ lines.push('( rm -f -- "$0" ) &');
117
+ lines.push(input.command);
118
+ lines.push('rm -f -- "$0"');
119
+ atomicWriteFile(scriptPath, `${lines.join("\n")}\n`, { mode: 0o600 });
120
+ launchScriptRegistry.set(scriptPath, Date.now());
121
+ return scriptPath;
122
+ }
123
+
124
+ /**
125
+ * Xóa mọi entry cũ hơn LAUNCH_SCRIPT_TTL_MS khỏi đĩa và registry, trả số entry
126
+ * đã dọn. Idempotent: entry mà file đã biến mất (worker tự rm) vẫn bị dọn khỏi
127
+ * registry; lỗi đĩa không throw — registry vẫn drop để không leak entry.
128
+ */
129
+ export function sweepLaunchScripts(registry: Map<string, number>, now: number): number {
130
+ let swept = 0;
131
+ for (const [scriptPath, createdAt] of [...registry.entries()]) {
132
+ if (now - createdAt <= LAUNCH_SCRIPT_TTL_MS) continue;
133
+ try {
134
+ fs.rmSync(scriptPath, { force: true });
135
+ } catch {
136
+ // best-effort — entry vẫn bị drop khỏi registry bên dưới
137
+ }
138
+ registry.delete(scriptPath);
139
+ swept++;
140
+ }
141
+ return swept;
142
+ }
143
+
144
+ /**
145
+ * Disk sweep cho script mồ côi TỪ PROCESS KHÁC (T12, optional T5): registry chỉ
146
+ * biết script của process hiện tại — host chết giữa run để lại file chứa token
147
+ * broker trên đĩa mà không entry nào trỏ tới. Doctor quét {baseDir} theo glob
148
+ * `pi-crew-launch-*.sh` và xóa file cũ hơn TTL theo mtime. Trả số file đã dọn
149
+ * (best-effort — lỗi đĩa từng file bị bỏ qua).
150
+ */
151
+ export function sweepOrphanLaunchScriptFiles(baseDir: string, now: number): number {
152
+ let swept = 0;
153
+ let entries: string[];
154
+ try {
155
+ entries = fs.readdirSync(baseDir);
156
+ } catch {
157
+ return 0; // baseDir không tồn tại/đọc lỗi — không có gì để dọn
158
+ }
159
+ for (const name of entries) {
160
+ if (!/^pi-crew-launch-.*\.sh$/.test(name)) continue;
161
+ const scriptPath = path.join(baseDir, name);
162
+ try {
163
+ const mtimeMs = fs.statSync(scriptPath).mtimeMs;
164
+ if (now - mtimeMs <= LAUNCH_SCRIPT_TTL_MS) continue;
165
+ fs.rmSync(scriptPath, { force: true });
166
+ swept++;
167
+ } catch {
168
+ // best-effort — file biến mất giữa readdir và stat/rm thì đã đạt mục tiêu
169
+ }
170
+ }
171
+ return swept;
172
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * resolveSurface — fail-closed multiplexer detection matrix (spec §3)
3
+ *
4
+ * Decides WHERE a tier-1 worker lives: a pane in tmux/herdr, or headless.
5
+ * Every failed check (missing binary, dead socket, depth, async run, pane
6
+ * cap, mode) degrades to headless (null). This NEVER throws because a
7
+ * multiplexer is missing — the current headless code path stays untouched.
8
+ *
9
+ * Check order per cell: binary first, env after. tmux beats herdr when both
10
+ * are present (innermost wins). Forced mode ("tmux"/"herdr") that fails
11
+ * detect → null, never falls through to the other backend.
12
+ */
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { Worker } from "node:worker_threads";
16
+
17
+ import type { PiTeamsConfig } from "../../config/types.ts";
18
+ import { currentCrewDepth } from "../model/pi-args.ts";
19
+ import { createHerdrProvider, herdrSocketPath } from "./herdr-provider.ts";
20
+ import type { SurfaceProvider } from "./surface-provider.ts";
21
+ import { createTmuxProvider } from "./tmux-provider.ts";
22
+
23
+ /** Hard cap on live surface panes per run (D6). Reaching it → headless. */
24
+ export const MAX_SURFACE_WORKERS = 6;
25
+
26
+ /** Socket connect timeout for the herdr liveness probe. */
27
+ const HERDR_PING_TIMEOUT_MS = 500;
28
+
29
+ /** Provider instances keyed by kind — injected so tests stay independent of T3/T4. */
30
+ export interface SurfaceProviders {
31
+ tmux?: SurfaceProvider;
32
+ herdr?: SurfaceProvider;
33
+ }
34
+
35
+ export interface ResolveSurfaceOpts {
36
+ /** tmux binary to probe (default: PATH lookup of "tmux"). */
37
+ tmuxBin?: string;
38
+ /** herdr binary to probe (default: PATH lookup of "herdr"). */
39
+ herdrBin?: string;
40
+ /** Synchronous socket liveness probe (default: net.connect with timeout). */
41
+ pingSocket?: (socketPath: string) => boolean;
42
+ /** Provider instances to return on successful detection. */
43
+ providers?: SurfaceProviders;
44
+ }
45
+
46
+ // Binary availability cache — same shape as hasCommand in amos tmux helpers:
47
+ // `command -v` is a subprocess, so memoize per binary path for the hot path.
48
+ const binaryAvailability = new Map<string, boolean>();
49
+
50
+ // tmux provider singleton — mọi pane của process này chia sẻ 1 onExit poll
51
+ // interval trong provider, nên resolveSurface phải trả về cùng instance.
52
+ let tmuxProviderSingleton: SurfaceProvider | null = null;
53
+ // herdr provider singleton — tương tự: 1 subscription connection chung.
54
+ let herdrProviderSingleton: SurfaceProvider | null = null;
55
+
56
+ function hasBinary(bin: string): boolean {
57
+ const cached = binaryAvailability.get(bin);
58
+ if (cached !== undefined) return cached;
59
+ let available = false;
60
+ try {
61
+ execFileSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" });
62
+ available = true;
63
+ } catch {
64
+ available = false;
65
+ }
66
+ binaryAvailability.set(bin, available);
67
+ return available;
68
+ }
69
+
70
+ // herdr socket path dùng chung contract từ provider (T4):
71
+ // HERDR_SOCKET_PATH → HERDR_SESSION (sessions/<name>/) → default location.
72
+
73
+ // The liveness probe runs in a Worker so the main thread can block on
74
+ // Atomics.wait while the worker's event loop drives net.connect to
75
+ // completion. Plain CJS string — no bundler path rewriting needed.
76
+ const PING_WORKER_SRC = `
77
+ const { parentPort, workerData } = require("node:worker_threads");
78
+ const net = require("node:net");
79
+ const flag = new Int32Array(workerData.sab);
80
+ const done = (ok) => {
81
+ if (Atomics.load(flag, 0) !== 0) return;
82
+ Atomics.store(flag, 0, ok ? 1 : 2);
83
+ Atomics.notify(flag, 0);
84
+ };
85
+ try {
86
+ const socket = net.connect({ path: workerData.socketPath });
87
+ const timer = setTimeout(() => { socket.destroy(); done(false); }, workerData.timeoutMs);
88
+ socket.on("connect", () => { clearTimeout(timer); socket.destroy(); done(true); });
89
+ socket.on("error", () => { clearTimeout(timer); done(false); });
90
+ } catch {
91
+ done(false);
92
+ }
93
+ parentPort.unref();
94
+ `;
95
+
96
+ /**
97
+ * Synchronous unix-socket connect probe. True = something is listening and
98
+ * accepted the connection. Any failure (missing socket, refusal, timeout,
99
+ * Worker/Atomics unavailable on this runtime) → false → fail-closed headless.
100
+ */
101
+ function pingSocketSync(socketPath: string, timeoutMs = HERDR_PING_TIMEOUT_MS): boolean {
102
+ const sab = new SharedArrayBuffer(4);
103
+ const flag = new Int32Array(sab);
104
+ let worker: Worker;
105
+ try {
106
+ worker = new Worker(PING_WORKER_SRC, {
107
+ eval: true,
108
+ workerData: { socketPath, timeoutMs, sab },
109
+ });
110
+ worker.unref();
111
+ } catch {
112
+ return false;
113
+ }
114
+ try {
115
+ // +150ms grace for worker startup beyond the connect timeout itself.
116
+ Atomics.wait(flag, 0, 0, timeoutMs + 150);
117
+ } catch {
118
+ return false;
119
+ } finally {
120
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional fire-and-forget — a dying worker has nothing left to fail on.
121
+ void worker.terminate().catch(() => {});
122
+ }
123
+ return Atomics.load(flag, 0) === 1;
124
+ }
125
+
126
+ /**
127
+ * Gate nào đã từ chối surface — telemetry `worker.surface_gate_blocked`
128
+ * (FINDING-3, report 10tier 2026-08-27): gate-null path trước đây trả null
129
+ * câm, khiến "headless vì misconfig" không thể phân biệt với "headless vì
130
+ * đúng thiết kế" trong events.jsonl.
131
+ */
132
+ export type SurfaceGateName = "mode-off" | "depth" | "pane-cap" | "role-not-visible" | "no-mux";
133
+
134
+ /**
135
+ * Snapshot các tín hiệu env gate đã thấy — có chủ đích KHÔNG dump cả env (secrets).
136
+ * `asyncRun` chỉ để telemetry (run là async/background), KHÔNG còn là gate nào
137
+ * cả — kể từ 2026-08-27, surface bỏ hard-gate "async → headless"; pane có hay
138
+ * không do môi trường quét được + `runtime.surface.*` config quyết.
139
+ */
140
+ export interface SurfaceGateEnvSnapshot {
141
+ tmux: boolean;
142
+ herdrEnv: boolean;
143
+ asyncRun: boolean;
144
+ depth: number;
145
+ }
146
+
147
+ export interface SurfaceGateRejection {
148
+ gate: SurfaceGateName;
149
+ /** Human-readable — kể vì sao cell/gate fail (đi thẳng vào events.jsonl). */
150
+ reason: string;
151
+ env: SurfaceGateEnvSnapshot;
152
+ }
153
+
154
+ export interface SurfaceResolution {
155
+ provider: SurfaceProvider | null;
156
+ /** Present khi provider null — gate/mux nào đã từ chối và vì sao. */
157
+ rejection?: SurfaceGateRejection;
158
+ }
159
+
160
+ export function surfaceGateEnvSnapshot(env: NodeJS.ProcessEnv): SurfaceGateEnvSnapshot {
161
+ return {
162
+ tmux: !!env.TMUX,
163
+ herdrEnv: env.HERDR_ENV === "1",
164
+ asyncRun: env.PI_CREW_ASYNC_RUN === "1",
165
+ depth: currentCrewDepth(env),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Resolve the surface provider for a tier-1 worker, or null for headless.
171
+ *
172
+ * Matrix (spec §3), checked in order — first hit wins:
173
+ * 1. surface.mode "off" → null
174
+ * 2. PI_CREW_ASYNC_RUN=1 (async run, A1) → null
175
+ * 3. PI_CREW_DEPTH > 0 (we are a worker/grandchild) → null — no pane-in-pane
176
+ * 4. livePaneCount >= MAX_SURFACE_WORKERS → null
177
+ * 5. role not in visibleAgents (exact match; ["*"] = all) → null
178
+ * 6. auto: TMUX + binary → tmux, else HERDR_ENV + binary + live socket →
179
+ * herdr; forced mode only tries its own cell, fail → null
180
+ *
181
+ * Providers: tmux dùng createTmuxProvider (T3), herdr dùng
182
+ * createHerdrProvider (T4) — mỗi kind một singleton để mọi pane của process
183
+ * này chia sẻ event subscription; injected `opts.providers` thắng cho test.
184
+ */
185
+ export function resolveSurfaceDetailed(
186
+ env: NodeJS.ProcessEnv,
187
+ config: PiTeamsConfig,
188
+ role: string,
189
+ livePaneCount: number,
190
+ opts: ResolveSurfaceOpts = {},
191
+ ): SurfaceResolution {
192
+ const surface = config.runtime?.surface;
193
+ const mode = surface?.mode ?? "auto";
194
+ const reject = (gate: SurfaceGateName, reason: string): SurfaceResolution => ({
195
+ provider: null,
196
+ rejection: { gate, reason, env: surfaceGateEnvSnapshot(env) },
197
+ });
198
+ if (mode === "off") return reject("mode-off", 'runtime.surface.mode is "off"');
199
+ // Surface panes are tier-1 only — never inside a worker.
200
+ const hostDepth = currentCrewDepth(env);
201
+ if (hostDepth > 0) return reject("depth", `host PI_CREW_DEPTH=${hostDepth} > 0 — no pane-in-pane (tier-1 workers only)`);
202
+ if (livePaneCount >= MAX_SURFACE_WORKERS)
203
+ return reject("pane-cap", `livePaneCount ${livePaneCount} >= MAX_SURFACE_WORKERS ${MAX_SURFACE_WORKERS}`);
204
+
205
+ const visibleAgents = surface?.visibleAgents ?? [];
206
+ if (!visibleAgents.includes("*") && !visibleAgents.includes(role))
207
+ return reject("role-not-visible", `role "${role}" not in visibleAgents [${visibleAgents.join(", ")}]`);
208
+
209
+ // Per cell: binary first, env after (cheap env read after the cached
210
+ // subprocess check; the herdr ping — most expensive — runs last).
211
+ const tmuxBin = opts.tmuxBin ?? "tmux";
212
+ const herdrBin = opts.herdrBin ?? "herdr";
213
+ const tmuxWhy = (): string => (!hasBinary(tmuxBin) ? "tmux binary not found" : "TMUX unset");
214
+ const herdrWhy = (): string =>
215
+ !hasBinary(herdrBin) ? "herdr binary not found" : env.HERDR_ENV !== "1" ? "HERDR_ENV!=1" : "socket not live";
216
+ const tmuxCell = (): boolean => hasBinary(tmuxBin) && !!env.TMUX;
217
+ const herdrCell = (): boolean =>
218
+ hasBinary(herdrBin) && env.HERDR_ENV === "1" && (opts.pingSocket ?? pingSocketSync)(herdrSocketPath(env));
219
+
220
+ let kind: "tmux" | "herdr" | null;
221
+ if (mode === "tmux") {
222
+ kind = tmuxCell() ? "tmux" : null;
223
+ } else if (mode === "herdr") {
224
+ kind = herdrCell() ? "herdr" : null;
225
+ } else {
226
+ // auto — innermost wins: tmux beats herdr when both are present.
227
+ kind = tmuxCell() ? "tmux" : herdrCell() ? "herdr" : null;
228
+ }
229
+ if (kind === null) {
230
+ const detail = mode === "tmux" ? tmuxWhy() : mode === "herdr" ? herdrWhy() : `tmux: ${tmuxWhy()}; herdr: ${herdrWhy()}`;
231
+ return reject("no-mux", `mode "${mode}" found no live mux (${detail})`);
232
+ }
233
+
234
+ // Injected providers thắng (test); mặc định dùng provider thật — mỗi kind
235
+ // một singleton để mọi pane của process này chia sẻ event subscription.
236
+ const injected = opts.providers?.[kind];
237
+ if (injected) return { provider: injected };
238
+ if (kind === "tmux") {
239
+ tmuxProviderSingleton ??= createTmuxProvider();
240
+ return { provider: tmuxProviderSingleton };
241
+ }
242
+ herdrProviderSingleton ??= createHerdrProvider();
243
+ return { provider: herdrProviderSingleton };
244
+ }
245
+
246
+ /** Wrapper giữ contract cũ (provider | null) — dùng resolveSurfaceDetailed khi cần lý do gate. */
247
+ export function resolveSurface(
248
+ env: NodeJS.ProcessEnv,
249
+ config: PiTeamsConfig,
250
+ role: string,
251
+ livePaneCount: number,
252
+ opts: ResolveSurfaceOpts = {},
253
+ ): SurfaceProvider | null {
254
+ return resolveSurfaceDetailed(env, config, role, livePaneCount, opts).provider;
255
+ }
256
+
257
+ /**
258
+ * Doctor orphan-pane cleanup (T12): provider singleton THEO KIND, không qua gate
259
+ * matrix §3 — doctor dọn pane mồ côi chứ không spawn worker mới, nên các gate
260
+ * depth/cap không áp dụng. Caller tự gọi detect() và chỉ close khi mux
261
+ * còn sống; trả null khi constructor throw (never — nhưng doctor fail-open list-only).
262
+ */
263
+ export function surfaceProviderForCleanup(kind: "tmux" | "herdr"): SurfaceProvider | null {
264
+ try {
265
+ if (kind === "tmux") {
266
+ tmuxProviderSingleton ??= createTmuxProvider();
267
+ return tmuxProviderSingleton;
268
+ }
269
+ herdrProviderSingleton ??= createHerdrProvider();
270
+ return herdrProviderSingleton;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * SurfaceProvider interface and types (spec §4)
3
+ *
4
+ * Defines the abstraction layer for multiplexer-based interactive surfaces (tmux, herdr).
5
+ * Used by runtime layer to create, attach, read, and close interactive sessions.
6
+ */
7
+
8
+ /**
9
+ * Detection result for a surface provider
10
+ */
11
+ export interface SurfaceDetection {
12
+ ok: boolean;
13
+ kind?: "tmux" | "herdr";
14
+ reason?: string;
15
+ }
16
+
17
+ /** Max worker panes per tab trước khi provider mở tab mới (spec tab-layout §5). */
18
+ export const MAX_PANES_PER_TAB = 8;
19
+
20
+ /**
21
+ * Hướng split luân phiên theo pane index trong tab (spec tab-layout §4):
22
+ * chẵn → down (dọc), lẻ → right (ngang) — không dồn một phía.
23
+ */
24
+ export function splitDirectionFor(index: number): "down" | "right" {
25
+ return index % 2 === 0 ? "down" : "right";
26
+ }
27
+
28
+ /**
29
+ * Options for spawning a new surface.
30
+ *
31
+ * `command` is OPTIONAL on purpose: the MuxSurface spawn flow (spec §13.1)
32
+ * creates the pane FIRST to learn its id, builds the launch script with
33
+ * PI_CREW_SURFACE_PANE=<real id>, and only then boots the worker through
34
+ * {@link SurfaceProvider.sendCommand}. Omitting it leaves the pane sitting at
35
+ * its shell prompt without any keys being sent.
36
+ */
37
+ export interface SurfaceSpawnOpts {
38
+ cwd: string;
39
+ command?: string;
40
+ title?: string;
41
+ /** runId — mọi worker của cùng TEAM RUN chia tab (spec tab-layout §3.1). */
42
+ tabKey?: string;
43
+ /**
44
+ * HINT từ caller (live pane count lúc spawn) — provider KHÔNG dùng field
45
+ * này quyết hướng: counter nội bộ paneCount (deferred-commit) mới là chân
46
+ * truth vì đó là số pane THẬT đã spawn trong tab.
47
+ */
48
+ splitIndex?: number;
49
+ }
50
+
51
+ /**
52
+ * Reason why a surface session ended
53
+ */
54
+ export type SurfaceExitReason = "pane-closed" | "mux-dead" | "detached";
55
+
56
+ /**
57
+ * Handle to an active surface session
58
+ */
59
+ export interface SurfaceHandle {
60
+ id: string;
61
+ kind: "tmux" | "herdr";
62
+ /**
63
+ * Tab/window chứa pane này (tab-layout, spec 2026-08-27 §5) — provider set
64
+ * khi spawn trong tab-flow (tabKey có mặt); caller ghi manifest
65
+ * `surface.tabs[tabKey]` để run end biết đóng tab nào. Vắng mặt = spawn
66
+ * ngoài run (đường legacy) hoặc provider cũ.
67
+ */
68
+ tabId?: string;
69
+ onExit(cb: (reason: SurfaceExitReason) => void): void;
70
+ dispose(): void;
71
+ }
72
+
73
+ /**
74
+ * Provider interface for multiplexer-based interactive surfaces
75
+ *
76
+ * Implementations:
77
+ * - tmux: Terminal multiplexer (via libtmux)
78
+ * - herdr: Custom herd-based multiplexer (future)
79
+ */
80
+ export interface SurfaceProvider {
81
+ /** Provider kind identifier */
82
+ kind: "tmux" | "herdr";
83
+
84
+ /** Detect if the multiplexer is available and functional */
85
+ detect(): SurfaceDetection;
86
+
87
+ /** Create a new surface session */
88
+ createSurface(name: string, opts: SurfaceSpawnOpts): Promise<SurfaceHandle>;
89
+
90
+ /**
91
+ * Send literal text into an existing surface session (as if typed by the
92
+ * user, followed by Enter). Both shipped providers implement it — the only
93
+ * way the host boots a worker in a pane that was created without a command
94
+ * (spec §13.1). Optional on the interface so pre-existing fake providers in
95
+ * tests keep compiling; callers treat "absent" as spawn failure and degrade.
96
+ */
97
+ sendCommand?(handle: SurfaceHandle, text: string): Promise<void>;
98
+
99
+ /** Attach to an existing surface session (returns null if not found/implemented) */
100
+ attach(id: string): SurfaceHandle | null;
101
+
102
+ /** Read current screen content from surface */
103
+ readScreen(handle: SurfaceHandle, lines?: number): Promise<string>;
104
+
105
+ /** Close/terminate a surface session */
106
+ closeSurface(handle: SurfaceHandle, opts?: { force?: boolean }): Promise<void>;
107
+
108
+ /**
109
+ * Đóng TOÀN bộ tab của một run theo tab-key (spec tab-layout §5: tab chỉ
110
+ * đóng khi run end/cancel/kill — không đóng theo từng worker). Provider tự
111
+ * tra map nội bộ tabKey → windows/tabs của run nên caller gọi ĐÚNG MỘT LẦN
112
+ * cho mỗi tabKey, không loop từng tabId. Optional để fake provider cũ
113
+ * trong test vẫn compile; idempotent (map trống / tab đã mất → no-op).
114
+ */
115
+ closeTab?(tabKey: string): Promise<void>;
116
+
117
+ /**
118
+ * Doctor cleanup-by-id (tab-layout Task 6): đóng MỘT tab/window theo id
119
+ * đã ghi trong manifest `surface.tabs` — KHÔNG qua map nội bộ tabKey của
120
+ * {@link closeTab}, vì doctor chạy ở TIẾN TRÌNH KHÁC host đã spawn (map
121
+ * trống ở đó → closeTab luôn no-op). Liveness lấy từ chính mux: trả
122
+ * `"gone"` khi mux không còn biết tab (đã tự chết — không có gì để đóng),
123
+ * `"closed"` khi doctor đã đóng; throw cho lỗi thật (doctor ghi failure).
124
+ * Idempotent; optional để fake provider cũ trong test vẫn compile.
125
+ */
126
+ closeTabById?(tabId: string): Promise<"closed" | "gone">;
127
+
128
+ /** rebalance(): void — A2 defer (not implemented in A1) */
129
+ }