pi-better-subagents 0.1.0
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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
package/registry.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run registry — durable metadata for each spawned subagent.
|
|
3
|
+
*
|
|
4
|
+
* The authoritative record for every run is a `meta.json` sidecar on disk, so
|
|
5
|
+
* `list` / `output` / `result` keep working across foreground turns, `/reload`,
|
|
6
|
+
* and even a full pi restart. In-memory state holds only the live exit handlers
|
|
7
|
+
* for runs this process spawned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { processExists } from "./spawn.ts";
|
|
14
|
+
import type { LifecycleClassification } from "./lifecycle.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Terminal + live statuses as recorded on disk. `orphaned` is durable and
|
|
18
|
+
* NON-terminal (supervision broke, but related processes may still be alive);
|
|
19
|
+
* `lost` is durable and terminal (no related process evidence remains).
|
|
20
|
+
*/
|
|
21
|
+
export type RunStatus = "running" | "completed" | "failed" | "killed" | "orphaned" | "lost";
|
|
22
|
+
|
|
23
|
+
export interface RunCallbackOrigin {
|
|
24
|
+
cwd: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when coherent child-exit evidence may finalize a run in this status.
|
|
30
|
+
* `running` is the normal path; `orphaned`/`lost` are PROVISIONAL
|
|
31
|
+
* reconciliation verdicts (a health tick can observe the just-exited pid
|
|
32
|
+
* before the close handler runs) that the real exit — the stronger evidence —
|
|
33
|
+
* supersedes. True terminal records are never overwritten: finalization is
|
|
34
|
+
* idempotent (`completed`/`failed`) and a deliberate `subagent_stop` kill
|
|
35
|
+
* (`killed`) is not undone by the resulting exit.
|
|
36
|
+
*/
|
|
37
|
+
export function canExitFinalize(status: RunStatus): boolean {
|
|
38
|
+
return status === "running" || status === "orphaned" || status === "lost";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RunMeta {
|
|
42
|
+
id: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
status: RunStatus;
|
|
45
|
+
/** Child process PID. */
|
|
46
|
+
pid: number;
|
|
47
|
+
/** Child's process group id, captured at spawn where available (#63). */
|
|
48
|
+
pgid?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Opaque process-start identity token captured at spawn where available
|
|
51
|
+
* (#63). Only equality is meaningful: a different token means the pid was
|
|
52
|
+
* recycled by an unrelated process.
|
|
53
|
+
*/
|
|
54
|
+
pidStartTime?: string;
|
|
55
|
+
/** Consecutive pid-gone health ticks with no related evidence (old metadata). */
|
|
56
|
+
probeMisses?: number;
|
|
57
|
+
/** When supervision was observed broken (transition to `orphaned`). */
|
|
58
|
+
orphanedAt?: number;
|
|
59
|
+
/** When the last related process evidence disappeared (transition to `lost`). */
|
|
60
|
+
lostAt?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Durable per-status health-callback handoff markers (#65).
|
|
63
|
+
* Written only after a successful coordinator handoff (sendMessage returned,
|
|
64
|
+
* or callback:false suppressed the model path intentionally). Once set,
|
|
65
|
+
* repeated health ticks and /reload must not re-fire that status. A missing
|
|
66
|
+
* marker on orphaned/lost means recovery must still attempt delivery.
|
|
67
|
+
* Independent of completion callbacks.
|
|
68
|
+
*/
|
|
69
|
+
orphanedCallbackSentAt?: number;
|
|
70
|
+
lostCallbackSentAt?: number;
|
|
71
|
+
/** PID of the pi process that launched this run (for cross-restart ownership). */
|
|
72
|
+
spawnPid: number;
|
|
73
|
+
model?: string;
|
|
74
|
+
cwd: string;
|
|
75
|
+
/** First ~200 chars of the task prompt, for listings. */
|
|
76
|
+
promptPreview: string;
|
|
77
|
+
startedAt: number;
|
|
78
|
+
endedAt?: number;
|
|
79
|
+
exitCode?: number | null;
|
|
80
|
+
/** Why an otherwise-zero exit is recorded as a non-success. */
|
|
81
|
+
failureReason?: "incomplete-stream";
|
|
82
|
+
/** Named lifecycle quality from exit/stream validation. */
|
|
83
|
+
lifecycleClassification?: LifecycleClassification;
|
|
84
|
+
logPath: string;
|
|
85
|
+
/** Child subagent session id. */
|
|
86
|
+
sessionId: string;
|
|
87
|
+
/** Foreground session that is allowed to receive unsolicited callbacks. */
|
|
88
|
+
callbackOrigin?: RunCallbackOrigin;
|
|
89
|
+
completionCallbackSuppressedAt?: number;
|
|
90
|
+
completionCallbackSuppressedReason?: string;
|
|
91
|
+
orphanedCallbackSuppressedAt?: number;
|
|
92
|
+
orphanedCallbackSuppressedReason?: string;
|
|
93
|
+
lostCallbackSuppressedAt?: number;
|
|
94
|
+
lostCallbackSuppressedReason?: string;
|
|
95
|
+
/** Writable dir the child is OS-sandboxed to, if any. */
|
|
96
|
+
sandbox?: string;
|
|
97
|
+
/** Whether completion posts the result back to the main session (default true). */
|
|
98
|
+
callback?: boolean;
|
|
99
|
+
/** Batch ID for runs launched via subagent_spawn_batch. */
|
|
100
|
+
batchId?: string;
|
|
101
|
+
/** Optional batch display name. */
|
|
102
|
+
batchName?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Durable navigator-dismissal timestamp (ms since epoch). Optional and
|
|
105
|
+
* additive: pre-existing metadata without this field parses unchanged, so
|
|
106
|
+
* no migration is required. Dismissal is navigator-organization ONLY — it
|
|
107
|
+
* never deletes logs, prompt, session data, metadata, or id-based tool
|
|
108
|
+
* access (`subagent_output` / `subagent_result` / `subagent_stop` /
|
|
109
|
+
* `subagent_list` keep working for dismissed runs).
|
|
110
|
+
*/
|
|
111
|
+
dismissedAt?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Root runtime dir, deliberately OUTSIDE any repo. */
|
|
115
|
+
export function baseDir(): string {
|
|
116
|
+
return join(tmpdir(), "pi-better-subagents");
|
|
117
|
+
}
|
|
118
|
+
export function sessionsDir(): string {
|
|
119
|
+
return join(baseDir(), "sessions");
|
|
120
|
+
}
|
|
121
|
+
export function runDir(id: string): string {
|
|
122
|
+
return join(baseDir(), "runs", id);
|
|
123
|
+
}
|
|
124
|
+
export function logPathFor(id: string): string {
|
|
125
|
+
return join(runDir(id), "output.log");
|
|
126
|
+
}
|
|
127
|
+
export function promptPathFor(id: string): string {
|
|
128
|
+
return join(runDir(id), "prompt.md");
|
|
129
|
+
}
|
|
130
|
+
function metaPathFor(id: string): string {
|
|
131
|
+
return join(runDir(id), "meta.json");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let seq = 0;
|
|
135
|
+
/** Monotonic, readable, collision-free run id: `sa_<base36-time>_<seq>`. */
|
|
136
|
+
export function nextRunId(): string {
|
|
137
|
+
seq += 1;
|
|
138
|
+
return `sa_${Date.now().toString(36)}_${seq}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function writeMeta(meta: RunMeta): void {
|
|
142
|
+
mkdirSync(runDir(meta.id), { recursive: true });
|
|
143
|
+
writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readMeta(id: string): RunMeta | undefined {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(readFileSync(metaPathFor(id), "utf-8")) as RunMeta;
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** All runs, newest first. */
|
|
155
|
+
export function listMetas(): RunMeta[] {
|
|
156
|
+
let ids: string[];
|
|
157
|
+
try {
|
|
158
|
+
ids = readdirSync(join(baseDir(), "runs"));
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return ids
|
|
163
|
+
.map(readMeta)
|
|
164
|
+
.filter((m): m is RunMeta => m !== undefined)
|
|
165
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reconcile the recorded status with reality for display. A run marked
|
|
170
|
+
* "running" whose PID is no longer alive exited without our handler firing
|
|
171
|
+
* (foreground pi was closed / restarted) — surface that as "exited".
|
|
172
|
+
*/
|
|
173
|
+
export function effectiveStatus(meta: RunMeta): RunStatus | "exited" {
|
|
174
|
+
if (meta.status !== "running") return meta.status;
|
|
175
|
+
if (processExists(meta.pid)) return "running";
|
|
176
|
+
return "exited";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True when a status can yield a FINAL result: everything except `running`
|
|
181
|
+
* and the non-terminal `orphaned`. `lost` is terminal (best-available
|
|
182
|
+
* artifacts, never a completion); `exited` keeps its historic resultable
|
|
183
|
+
* treatment. Used by subagent_result's gate.
|
|
184
|
+
*/
|
|
185
|
+
export function isFinalResultStatus(status: RunStatus | "exited"): boolean {
|
|
186
|
+
return status !== "running" && status !== "orphaned";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when this meta was spawned by the given parent pi PID (default: this process). */
|
|
190
|
+
export function ownedByThisParent(
|
|
191
|
+
meta: Pick<RunMeta, "spawnPid">,
|
|
192
|
+
parentPid: number = process.pid,
|
|
193
|
+
): boolean {
|
|
194
|
+
return meta.spawnPid === parentPid;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when the run has been dismissed from the human navigator. */
|
|
198
|
+
export function isDismissed(meta: Pick<RunMeta, "dismissedAt">): boolean {
|
|
199
|
+
return typeof meta.dismissedAt === "number";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Mark a run dismissed from the navigator, durably. Idempotent: the first
|
|
204
|
+
* dismissal timestamp wins. Returns the updated meta, or undefined for an
|
|
205
|
+
* unknown id. Only the timestamp changes — all other metadata is preserved.
|
|
206
|
+
*/
|
|
207
|
+
export function dismissRun(id: string, at: number = Date.now()): RunMeta | undefined {
|
|
208
|
+
const meta = readMeta(id);
|
|
209
|
+
if (!meta) return undefined;
|
|
210
|
+
if (meta.dismissedAt === undefined) {
|
|
211
|
+
meta.dismissedAt = at;
|
|
212
|
+
writeMeta(meta);
|
|
213
|
+
}
|
|
214
|
+
return meta;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Visible navigator runs: current-parent runs that are not dismissed. This is
|
|
219
|
+
* the single visibility calculation shared by the navigator list and the
|
|
220
|
+
* footer count, so they can never drift apart. `subagent_list` does NOT use
|
|
221
|
+
* this — the model-facing list keeps showing dismissed runs.
|
|
222
|
+
*/
|
|
223
|
+
export function navigatorVisibleRuns(
|
|
224
|
+
metas: RunMeta[],
|
|
225
|
+
parentPid: number = process.pid,
|
|
226
|
+
): RunMeta[] {
|
|
227
|
+
return metas.filter((m) => ownedByThisParent(m, parentPid) && !isDismissed(m));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Footer-count seam: how many runs the navigator affordance advertises. */
|
|
231
|
+
export function navigatorVisibleCount(
|
|
232
|
+
metas: RunMeta[],
|
|
233
|
+
parentPid: number = process.pid,
|
|
234
|
+
): number {
|
|
235
|
+
return navigatorVisibleRuns(metas, parentPid).length;
|
|
236
|
+
}
|
package/sandbox.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-level write sandbox for subagent children.
|
|
3
|
+
*
|
|
4
|
+
* Kernel-enforced confinement: the child may READ anywhere and use the network
|
|
5
|
+
* (so web_fetch and the model API keep working), but may only WRITE under a
|
|
6
|
+
* single directory plus the system paths pi itself needs to function. Unlike the
|
|
7
|
+
* cooperative guardrails layer (which pattern-matches tool inputs), this cannot
|
|
8
|
+
* be evaded by a crafted bash command — the write syscall itself is denied.
|
|
9
|
+
*
|
|
10
|
+
* Backends are selected here so callers retain a platform-neutral support query
|
|
11
|
+
* and command-wrapper contract. Linux support adds a backend without changing
|
|
12
|
+
* detached spawning policy in the extension.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { platform } from "node:os";
|
|
16
|
+
import { accessSync, constants, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { delimiter, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
type SandboxCommandArgs = {
|
|
20
|
+
profilePath: string;
|
|
21
|
+
writableDir: string;
|
|
22
|
+
home: string;
|
|
23
|
+
piBin: string;
|
|
24
|
+
piArgs: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SandboxCommand = { file: string; fileArgs: string[] };
|
|
28
|
+
|
|
29
|
+
type SandboxBackend = {
|
|
30
|
+
buildCommand(args: SandboxCommandArgs): SandboxCommand;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type SandboxRequest = {
|
|
34
|
+
sandboxEnabled: boolean;
|
|
35
|
+
explicitSandbox: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Quote a path as an SBPL string literal. */
|
|
39
|
+
function sbpl(path: string): string {
|
|
40
|
+
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the existing macOS sandbox-exec wrapper and SBPL profile. */
|
|
44
|
+
function buildMacOSSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
|
|
45
|
+
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
46
|
+
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
47
|
+
let dir = args.writableDir;
|
|
48
|
+
try { dir = realpathSync(dir); } catch { /* not yet created; use as given */ }
|
|
49
|
+
|
|
50
|
+
const profile = [
|
|
51
|
+
"(version 1)",
|
|
52
|
+
"(allow default)", // permissive base: reads, exec, network
|
|
53
|
+
"(deny file-write*)", // ...then deny all writes...
|
|
54
|
+
`(allow file-write* (subpath ${sbpl(dir)}))`, // ...except here
|
|
55
|
+
`(allow file-write* (subpath ${sbpl(`${args.home}/.pi`)}))`, // pi state
|
|
56
|
+
'(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
|
|
57
|
+
'(allow file-write* (subpath "/private/tmp"))',
|
|
58
|
+
'(allow file-write* (subpath "/dev"))', // /dev/null etc.
|
|
59
|
+
"",
|
|
60
|
+
].join("\n");
|
|
61
|
+
writeFileSync(args.profilePath, profile);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
file: "/usr/bin/sandbox-exec",
|
|
65
|
+
fileArgs: ["-f", args.profilePath, args.piBin, ...args.piArgs],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const macOSSandboxBackend: SandboxBackend = {
|
|
70
|
+
buildCommand: buildMacOSSandboxCommand,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Resolve an executable from PATH without starting it or probing namespaces. */
|
|
74
|
+
function executableFromPath(name: string): string | undefined {
|
|
75
|
+
const path = process.env.PATH;
|
|
76
|
+
if (!path) return undefined;
|
|
77
|
+
|
|
78
|
+
for (const entry of path.split(delimiter)) {
|
|
79
|
+
const candidate = resolve(entry || ".", name);
|
|
80
|
+
try {
|
|
81
|
+
if (!statSync(candidate).isFile()) continue;
|
|
82
|
+
accessSync(candidate, constants.X_OK);
|
|
83
|
+
return candidate;
|
|
84
|
+
} catch {
|
|
85
|
+
// A PATH entry may disappear or be inaccessible between lookup and use.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildLinuxSandboxCommand(bwrap: string, args: SandboxCommandArgs): SandboxCommand {
|
|
92
|
+
// The caller creates the selected work directory before it reaches this
|
|
93
|
+
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
94
|
+
// widening the writable root.
|
|
95
|
+
const dir = realpathSync(args.writableDir);
|
|
96
|
+
return {
|
|
97
|
+
file: bwrap,
|
|
98
|
+
fileArgs: [
|
|
99
|
+
"--ro-bind", "/", "/",
|
|
100
|
+
"--bind", dir, dir,
|
|
101
|
+
"--bind", "/tmp", "/tmp",
|
|
102
|
+
"--dev", "/dev",
|
|
103
|
+
"--",
|
|
104
|
+
args.piBin, ...args.piArgs,
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function linuxSandboxBackend(): SandboxBackend | undefined {
|
|
110
|
+
const bwrap = executableFromPath("bwrap");
|
|
111
|
+
if (!bwrap) return undefined;
|
|
112
|
+
return { buildCommand: (args) => buildLinuxSandboxCommand(bwrap, args) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function selectedSandboxBackend(): SandboxBackend | undefined {
|
|
116
|
+
const currentPlatform = platform();
|
|
117
|
+
if (currentPlatform === "darwin") return macOSSandboxBackend;
|
|
118
|
+
if (currentPlatform === "linux") return linuxSandboxBackend();
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sandboxUnavailableMessage(): string {
|
|
123
|
+
const currentPlatform = platform();
|
|
124
|
+
if (currentPlatform === "linux") {
|
|
125
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap or pass sandbox:false.";
|
|
126
|
+
}
|
|
127
|
+
if (currentPlatform === "darwin") {
|
|
128
|
+
return "macOS sandbox requires /usr/bin/sandbox-exec. Pass sandbox:false if it is unavailable.";
|
|
129
|
+
}
|
|
130
|
+
return `sandbox is unsupported on ${currentPlatform}. Pass sandbox:false on this platform.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when an OS write-sandbox backend can be applied on this platform. */
|
|
134
|
+
export function sandboxSupported(): boolean {
|
|
135
|
+
return selectedSandboxBackend() !== undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the caller's default-on, explicit-request, and opt-out policy before
|
|
140
|
+
* spawning. A selected backend always returns its wrapper; callers never retry
|
|
141
|
+
* the child directly when that wrapper exits or cannot initialize.
|
|
142
|
+
*/
|
|
143
|
+
export function maybeBuildSandboxCommand(
|
|
144
|
+
args: SandboxCommandArgs,
|
|
145
|
+
request: SandboxRequest,
|
|
146
|
+
): SandboxCommand | undefined {
|
|
147
|
+
if (!request.sandboxEnabled) return undefined;
|
|
148
|
+
|
|
149
|
+
const backend = selectedSandboxBackend();
|
|
150
|
+
if (!backend) {
|
|
151
|
+
if (request.explicitSandbox) throw new Error(sandboxUnavailableMessage());
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return backend.buildCommand(args);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Return the selected backend's executable and ordered argv wrapper around pi.
|
|
159
|
+
* The fallback preserves the pre-existing direct-call result for callers that
|
|
160
|
+
* bypass the request-policy helper above.
|
|
161
|
+
*/
|
|
162
|
+
export function buildSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
|
|
163
|
+
return (selectedSandboxBackend() ?? macOSSandboxBackend).buildCommand(args);
|
|
164
|
+
}
|
package/spawn.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached child-process spawn with file-backed output.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained (adapted from the pi-patty-bg-tasks process model): the kernel
|
|
5
|
+
* writes the child's stdout+stderr straight to a log file with zero JS in the
|
|
6
|
+
* data path, and the child is `detached` + `unref`'d so it is fully isolated
|
|
7
|
+
* from the foreground pi turn. Progress is read back later by tailing the file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { closeSync, mkdirSync, openSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface SpawnResult {
|
|
15
|
+
pid: number;
|
|
16
|
+
/** Resolves with the child's exit code (null on signal exit). Never rejects. */
|
|
17
|
+
exit: Promise<number | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Spawn `file fileArgs`, streaming stdout+stderr to `logPath`. Detached. */
|
|
21
|
+
export function spawnDetached(args: {
|
|
22
|
+
file: string;
|
|
23
|
+
fileArgs: string[];
|
|
24
|
+
cwd: string;
|
|
25
|
+
logPath: string;
|
|
26
|
+
}): SpawnResult {
|
|
27
|
+
mkdirSync(dirname(args.logPath), { recursive: true });
|
|
28
|
+
const outFd = openSync(args.logPath, "w");
|
|
29
|
+
|
|
30
|
+
let proc;
|
|
31
|
+
try {
|
|
32
|
+
proc = spawn(args.file, args.fileArgs, {
|
|
33
|
+
stdio: ["ignore", outFd, outFd],
|
|
34
|
+
cwd: args.cwd,
|
|
35
|
+
detached: true,
|
|
36
|
+
env: { ...process.env },
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
closeSync(outFd);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build the exit promise and attach the 'error' listener BEFORE any throw,
|
|
43
|
+
// so an async spawn failure (ENOENT / EMFILE / EAGAIN) can never surface as
|
|
44
|
+
// an uncaught exception that takes the foreground pi down.
|
|
45
|
+
const exit = new Promise<number | null>((resolve) => {
|
|
46
|
+
proc.on("close", (code) => resolve(code));
|
|
47
|
+
proc.on("error", () => resolve(1));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!proc.pid) {
|
|
51
|
+
try { unlinkSync(args.logPath); } catch { /* best-effort */ }
|
|
52
|
+
throw new Error("Failed to spawn subagent process");
|
|
53
|
+
}
|
|
54
|
+
const pid = proc.pid;
|
|
55
|
+
proc.unref();
|
|
56
|
+
return { pid, exit };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Kill an entire process group via negative-PID signal; fall back to the PID. */
|
|
60
|
+
export function killProcessTree(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
61
|
+
if (typeof pid !== "number" || pid <= 0) return;
|
|
62
|
+
try {
|
|
63
|
+
process.kill(-pid, signal);
|
|
64
|
+
} catch {
|
|
65
|
+
try { process.kill(pid, signal); } catch { /* already dead */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cheap liveness probe via signal 0. */
|
|
70
|
+
export function processExists(pid: number | undefined): boolean {
|
|
71
|
+
if (typeof pid !== "number" || pid <= 0) return false;
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
77
|
+
}
|
|
78
|
+
}
|
package/stop.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stop / orphaned-cleanup semantics for subagent runs.
|
|
3
|
+
*
|
|
4
|
+
* `stopRun` is the single stop path used by BOTH the model-facing
|
|
5
|
+
* `subagent_stop` tool and the TUI navigator close action (issues #44/#47/#68),
|
|
6
|
+
* so process-group termination and terminal-status recording can never diverge
|
|
7
|
+
* between the two surfaces.
|
|
8
|
+
*
|
|
9
|
+
* Race safety: the run's metadata and effective status are re-read from disk
|
|
10
|
+
* at call time, never taken from a caller's cached copy. A run that finished
|
|
11
|
+
* while a confirmation was pending is reported as not-running instead of
|
|
12
|
+
* being "terminated" against stale state.
|
|
13
|
+
*
|
|
14
|
+
* Orphaned cleanup (#68): when related process-group work is still alive,
|
|
15
|
+
* SIGTERM the recorded group and record durable `killed`. When no related
|
|
16
|
+
* process-group evidence remains, reread the child log and finalize from
|
|
17
|
+
* coherent terminal evidence (`completed` / `failed`) or record `lost`.
|
|
18
|
+
*
|
|
19
|
+
* Process-group-only contract (ADR 0002): related work is the captured
|
|
20
|
+
* process group (pgid, falling back to pid for old metadata). Escaped /
|
|
21
|
+
* reparented descendants outside that group are out of contract and are
|
|
22
|
+
* never scanned for.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { realProcessProbe, type ProcessProbe } from "./health.ts";
|
|
26
|
+
import { parseRunForLifecycle } from "./parse.ts";
|
|
27
|
+
import {
|
|
28
|
+
effectiveStatus,
|
|
29
|
+
readMeta,
|
|
30
|
+
writeMeta,
|
|
31
|
+
type RunMeta,
|
|
32
|
+
} from "./registry.ts";
|
|
33
|
+
import { killProcessTree } from "./spawn.ts";
|
|
34
|
+
|
|
35
|
+
export type StopOutcome =
|
|
36
|
+
| { action: "stopped"; id: string }
|
|
37
|
+
| { action: "finalized"; id: string; status: "completed" | "failed" | "lost" }
|
|
38
|
+
| { action: "not-running"; id: string; status: string };
|
|
39
|
+
|
|
40
|
+
export interface StopDeps {
|
|
41
|
+
/** OS process probe; defaults to the real probe. Tests inject fakes. */
|
|
42
|
+
probe?: ProcessProbe;
|
|
43
|
+
/** Clock seam for durable timestamps. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Recorded process group for related-work signals (ADR 0002). */
|
|
48
|
+
export function relatedProcessGroupId(meta: Pick<RunMeta, "pid" | "pgid">): number | undefined {
|
|
49
|
+
const pgid = meta.pgid ?? meta.pid;
|
|
50
|
+
return typeof pgid === "number" && pgid > 0 ? pgid : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when stop/close may still act on this effective status.
|
|
55
|
+
* `running` is the supervised path; `orphaned` is the #68 cleanup path.
|
|
56
|
+
*/
|
|
57
|
+
export function isStoppableStatus(status: string): boolean {
|
|
58
|
+
return status === "running" || status === "orphaned";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** SIGTERM the run's related process group. Returns whether a group target existed. */
|
|
62
|
+
function terminateRelatedWork(meta: Pick<RunMeta, "pid" | "pgid">): number | undefined {
|
|
63
|
+
const pgid = relatedProcessGroupId(meta);
|
|
64
|
+
if (pgid === undefined) return undefined;
|
|
65
|
+
killProcessTree(pgid, "SIGTERM");
|
|
66
|
+
return pgid;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recordKilled(meta: RunMeta, now: number): StopOutcome {
|
|
70
|
+
meta.status = "killed";
|
|
71
|
+
meta.lifecycleClassification = "killed";
|
|
72
|
+
meta.endedAt = now;
|
|
73
|
+
writeMeta(meta);
|
|
74
|
+
return { action: "stopped", id: meta.id };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* No related process-group evidence remains. Reread logs, then finalize from
|
|
79
|
+
* coherent terminal stream evidence or record durable `lost`.
|
|
80
|
+
*/
|
|
81
|
+
function finalizeOrphanedWithoutProcess(meta: RunMeta, now: number): StopOutcome {
|
|
82
|
+
// Log authority is reread at cleanup time — never trust a cached parse.
|
|
83
|
+
const run = parseRunForLifecycle(meta.id);
|
|
84
|
+
|
|
85
|
+
if (run.sawEnd && run.unmatchedToolCalls.length === 0) {
|
|
86
|
+
meta.status = "completed";
|
|
87
|
+
meta.lifecycleClassification = "complete";
|
|
88
|
+
if (meta.exitCode === undefined) meta.exitCode = 0;
|
|
89
|
+
meta.endedAt = now;
|
|
90
|
+
writeMeta(meta);
|
|
91
|
+
return { action: "finalized", id: meta.id, status: "completed" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (run.sawEnd && run.unmatchedToolCalls.length > 0) {
|
|
95
|
+
meta.status = "failed";
|
|
96
|
+
meta.lifecycleClassification = "incomplete_open_tools";
|
|
97
|
+
meta.failureReason = "incomplete-stream";
|
|
98
|
+
meta.endedAt = now;
|
|
99
|
+
writeMeta(meta);
|
|
100
|
+
return { action: "finalized", id: meta.id, status: "failed" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// No coherent terminal completion evidence and no related process remains.
|
|
104
|
+
meta.status = "lost";
|
|
105
|
+
meta.lifecycleClassification = "lost";
|
|
106
|
+
meta.lostAt = now;
|
|
107
|
+
meta.endedAt = now;
|
|
108
|
+
writeMeta(meta);
|
|
109
|
+
return { action: "finalized", id: meta.id, status: "lost" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cleanup path for a durable orphaned run (#68).
|
|
114
|
+
* Shares kill + killed recording with the running-run stop path when the
|
|
115
|
+
* captured process group is still alive; otherwise finalizes from logs.
|
|
116
|
+
*/
|
|
117
|
+
function cleanupOrphanedRun(meta: RunMeta, probe: ProcessProbe, now: number): StopOutcome {
|
|
118
|
+
const pgid = relatedProcessGroupId(meta);
|
|
119
|
+
if (pgid !== undefined && probe.groupAlive(pgid)) {
|
|
120
|
+
terminateRelatedWork(meta);
|
|
121
|
+
return recordKilled(meta, now);
|
|
122
|
+
}
|
|
123
|
+
return finalizeOrphanedWithoutProcess(meta, now);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stop or resolve a run by id.
|
|
128
|
+
*
|
|
129
|
+
* - `running` → SIGTERM related process group, durable `killed`.
|
|
130
|
+
* - `orphaned` → terminate identifiable process-group members when alive;
|
|
131
|
+
* otherwise reread logs and finalize completed/failed/lost.
|
|
132
|
+
* - other statuses → not-running (on-disk record untouched).
|
|
133
|
+
*
|
|
134
|
+
* Throws on an unknown id (same contract as the stop tool).
|
|
135
|
+
*/
|
|
136
|
+
export function stopRun(id: string, deps: StopDeps = {}): StopOutcome {
|
|
137
|
+
const meta = readMeta(id);
|
|
138
|
+
if (!meta) throw new Error(`Unknown run id: ${id}`);
|
|
139
|
+
const probe = deps.probe ?? realProcessProbe;
|
|
140
|
+
const now = typeof deps.now === "function" ? deps.now() : Date.now();
|
|
141
|
+
const status = effectiveStatus(meta);
|
|
142
|
+
|
|
143
|
+
if (status === "running") {
|
|
144
|
+
// Supervised path: same process-group kill + killed write as before.
|
|
145
|
+
// Prefer recorded pgid so running and orphaned share one target rule.
|
|
146
|
+
terminateRelatedWork(meta);
|
|
147
|
+
return recordKilled(meta, now);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (status === "orphaned") {
|
|
151
|
+
return cleanupOrphanedRun(meta, probe, now);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { action: "not-running", id, status };
|
|
155
|
+
}
|