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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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
|
+
}
|