@getpipher/armory-fleet 0.2.0 → 0.4.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/agents/general-purpose-cc.md +11 -0
- package/package.json +1 -1
- package/src/backend/claude-detector.ts +75 -0
- package/src/backend/claude-events.ts +39 -0
- package/src/backend/claude-factory.ts +50 -0
- package/src/backend/claude-session.ts +75 -0
- package/src/backend/hook-parity.ts +19 -0
- package/src/backend/port.ts +5 -0
- package/src/backend/registry.ts +36 -0
- package/src/backend/resume-store.ts +44 -0
- package/src/engine/run-registry.ts +4 -0
- package/src/engine/spawnSubagent.ts +46 -17
- package/src/index.ts +141 -7
- package/src/lifecycle/artifacts-parser.ts +57 -0
- package/src/lifecycle/default.ts +74 -0
- package/src/lifecycle/lifecycle-todo.ts +84 -0
- package/src/lifecycle/lifecycle-types.ts +66 -0
- package/src/lifecycle/port.ts +7 -0
- package/src/lifecycle/prompt-template.ts +40 -0
- package/src/lifecycle/registry.ts +169 -0
- package/src/lifecycle/run-lifecycle.ts +235 -0
- package/src/panel/fleet-panel.ts +205 -13
- package/src/panel/rows.ts +67 -2
- package/src/registry/frontmatter.ts +13 -0
- package/src/todo-sync/adapter.ts +6 -0
- package/src/todo-sync/port.ts +2 -0
- package/src/tools/subagent.ts +36 -3
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: general-purpose-cc
|
|
3
|
+
description: A focused general-purpose CC subagent. Use for any task needing Claude Code as the worker.
|
|
4
|
+
backend: claude
|
|
5
|
+
todoSync: true
|
|
6
|
+
memoryHydrate: true
|
|
7
|
+
vision: true
|
|
8
|
+
---
|
|
9
|
+
You are a focused subagent delegate running under Claude Code. Complete the assigned task
|
|
10
|
+
thoroughly, work autonomously to completion, and return a concise result summary.
|
|
11
|
+
Do not call the `todo` tool — the fleet engine manages todo tracking for you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/backend/claude-detector.ts — version + stream-json schema smoke + flag-support probe (SPEC-3 §5).
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mapClaudeEvent } from "./claude-events.ts";
|
|
5
|
+
import type { BackendVersionInfo } from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BIN = "claude";
|
|
8
|
+
|
|
9
|
+
export interface DetectOpts {
|
|
10
|
+
/** Fixture hook: an arg passed to the fake-claude via FLEET_FAKE_CLAUDE_PROBE env to select init-ok/init-drift. */
|
|
11
|
+
schemaProbeArg?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function run(bin: string, args: string[], env?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(bin, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
|
|
17
|
+
let stdout = "";
|
|
18
|
+
let stderr = "";
|
|
19
|
+
child.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
20
|
+
child.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
21
|
+
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
|
22
|
+
// Capture the spawn error message into stderr so the caller can detect ENOENT (binary missing).
|
|
23
|
+
child.on("error", (err) => resolve({ stdout: "", stderr: err.message, code: null }));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseVersion(stdout: string): string {
|
|
28
|
+
const m = stdout.trim().match(/(\d+\.\d+\.\d+)/);
|
|
29
|
+
return m && m[1] ? m[1] : stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function probeFlags(helpText: string): Record<string, boolean> {
|
|
33
|
+
const has = (flag: string): boolean => new RegExp(`(^|\\s)${flag.replace(/-/g, "\\-")}(\\s|$)`).test(helpText);
|
|
34
|
+
return {
|
|
35
|
+
"--disallowed-tools": has("--disallowed-tools"),
|
|
36
|
+
"--allowed-tools": has("--allowed-tools"),
|
|
37
|
+
"--max-turns": has("--max-turns"),
|
|
38
|
+
"--resume": has("--resume"),
|
|
39
|
+
"--append-system-prompt": has("--append-system-prompt"),
|
|
40
|
+
"--output-format": has("--output-format"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function detectClaude(bin: string = DEFAULT_BIN, opts: DetectOpts = {}): Promise<BackendVersionInfo | null> {
|
|
45
|
+
// Missing binary check: for an explicit path that doesn't exist, return null;
|
|
46
|
+
// for the default `claude` on PATH, the version run below resolves ENOENT → null.
|
|
47
|
+
if (bin !== DEFAULT_BIN && !existsSync(bin)) return null;
|
|
48
|
+
const versionRun = await run(bin, ["--version"]);
|
|
49
|
+
if (versionRun.code === null && /ENOENT/i.test(versionRun.stderr)) return null;
|
|
50
|
+
if (versionRun.code !== 0 && !versionRun.stdout) {
|
|
51
|
+
return { version: "", schemaOk: false, flagSupport: {}, note: `claude --version failed (code ${versionRun.code})` };
|
|
52
|
+
}
|
|
53
|
+
const version = parseVersion(versionRun.stdout);
|
|
54
|
+
|
|
55
|
+
// Schema smoke: spawn a throwaway ping in stream-json mode; scan the lines for a `system/init` event with session_id.
|
|
56
|
+
// (Real CC emits hook_started/hook_response system events before init; first-line-only would false-fail.)
|
|
57
|
+
const env = opts.schemaProbeArg ? { FLEET_FAKE_CLAUDE_PROBE: opts.schemaProbeArg } : undefined;
|
|
58
|
+
const smoke = await run(bin, ["-p", "--verbose", "--output-format", "stream-json", "ping"], env);
|
|
59
|
+
let schemaOk = false;
|
|
60
|
+
let note: string | undefined;
|
|
61
|
+
const lines = smoke.stdout.split("\n").filter((l) => l.trim());
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const ev = mapClaudeEvent(line);
|
|
64
|
+
if (ev && ev.type === "session_init" && ev.backendSessionId) { schemaOk = true; break; }
|
|
65
|
+
}
|
|
66
|
+
if (!schemaOk) {
|
|
67
|
+
note = lines.length ? `schema drift (no init event with session_id; first line: ${lines[0]!.slice(0, 80)})` : "schema drift (no output emitted)";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Flag-support probe (only meaningful if --help works).
|
|
71
|
+
const helpRun = await run(bin, ["--help"]);
|
|
72
|
+
const flagSupport = helpRun.code === 0 ? probeFlags(helpRun.stdout) : {};
|
|
73
|
+
|
|
74
|
+
return { version, schemaOk, flagSupport, note };
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
|
|
2
|
+
// Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
|
|
3
|
+
// The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
|
|
4
|
+
import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
|
+
|
|
6
|
+
interface CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record<string, unknown>; }
|
|
7
|
+
interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
|
|
8
|
+
|
|
9
|
+
export function mapClaudeEvent(line: string): ChildSessionEvent | null {
|
|
10
|
+
let ev: CCEvent;
|
|
11
|
+
try {
|
|
12
|
+
ev = JSON.parse(line) as CCEvent;
|
|
13
|
+
} catch {
|
|
14
|
+
return null; // malformed line — resilient
|
|
15
|
+
}
|
|
16
|
+
switch (ev.type) {
|
|
17
|
+
case "system":
|
|
18
|
+
if (ev.subtype === "init" && typeof ev.session_id === "string") {
|
|
19
|
+
return { type: "session_init", backendSessionId: ev.session_id };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
case "assistant": {
|
|
23
|
+
const msg = ev.message;
|
|
24
|
+
if (!msg) return null;
|
|
25
|
+
const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
|
|
26
|
+
const usage = msg.usage as { cost?: { total?: number } } | undefined;
|
|
27
|
+
return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } };
|
|
28
|
+
}
|
|
29
|
+
case "result":
|
|
30
|
+
// turn boundary (success or error_max_turns) → turn_end drives the budget
|
|
31
|
+
return { type: "turn_end" };
|
|
32
|
+
case "error":
|
|
33
|
+
return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
|
|
34
|
+
case "user":
|
|
35
|
+
return null; // echo of our own stdin write — filtered
|
|
36
|
+
default:
|
|
37
|
+
return null; // unknown — forward-compat, caller logs at debug
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/backend/claude-factory.ts — createClaudeChildFactory (SPEC-3 §4.1, §4.5, §4.6, §9.1).
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import type { ChildSessionFactory, ChildSessionOpts } from "../engine/spawnSubagent.ts";
|
|
4
|
+
import type { BackendVersionInfo } from "./registry.ts";
|
|
5
|
+
import type { ResumeStore } from "./resume-store.ts";
|
|
6
|
+
import { ClaudeChildSession } from "./claude-session.ts";
|
|
7
|
+
import { memoryScopesFor } from "../engine/child-loader.ts";
|
|
8
|
+
|
|
9
|
+
export interface ClaudeFactoryOverrides {
|
|
10
|
+
/** Test hook: called instead of `spawn` to inspect args. Returns a ChildProcess-shaped stub. */
|
|
11
|
+
spawnOverride?: (args: string[]) => ChildProcess;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createClaudeChildFactory(
|
|
15
|
+
detector: BackendVersionInfo | null,
|
|
16
|
+
resumeStore: ResumeStore,
|
|
17
|
+
bin: string = "claude",
|
|
18
|
+
overrides: ClaudeFactoryOverrides = {},
|
|
19
|
+
): ChildSessionFactory {
|
|
20
|
+
return {
|
|
21
|
+
async create(opts: ChildSessionOpts): Promise<{ session: ClaudeChildSession; model: string }> {
|
|
22
|
+
if (!detector?.schemaOk) {
|
|
23
|
+
throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
|
|
24
|
+
}
|
|
25
|
+
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
|
|
26
|
+
const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
|
|
27
|
+
const resumeId = resumeStore.get("claude", opts.agent.sessionKey);
|
|
28
|
+
|
|
29
|
+
const args: string[] = ["-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose"];
|
|
30
|
+
if (opts.model) args.push("--model", opts.model);
|
|
31
|
+
args.push("--append-system-prompt", sys);
|
|
32
|
+
// todo exclusion: prefer --disallowed-tools; fall back to --allowed-tools allow-list when the agent pins tools.
|
|
33
|
+
if (detector.flagSupport["--disallowed-tools"]) {
|
|
34
|
+
args.push("--disallowed-tools", "todo");
|
|
35
|
+
} else if (detector.flagSupport["--allowed-tools"] && opts.tools.length) {
|
|
36
|
+
const allowed = opts.tools.filter((t) => t !== "todo").join(",");
|
|
37
|
+
args.push("--allowed-tools", allowed);
|
|
38
|
+
}
|
|
39
|
+
// v0.3 leaves maxTurns to the engine's turn_end belt (no --max-turns flag; avoids double-enforcement).
|
|
40
|
+
if (resumeId && detector.flagSupport["--resume"]) args.push("--resume", resumeId);
|
|
41
|
+
args.push(opts.task);
|
|
42
|
+
|
|
43
|
+
const proc = overrides.spawnOverride
|
|
44
|
+
? overrides.spawnOverride(args)
|
|
45
|
+
: spawn(bin, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
46
|
+
const session = new ClaudeChildSession(proc, opts.agent.sessionKey, resumeStore);
|
|
47
|
+
return { session, model: opts.model ?? "" };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/backend/claude-session.ts — ChildSession over a claude -p child process (SPEC-3 §4.4, §4.3).
|
|
2
|
+
import type { ChildProcess } from "node:child_process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
|
+
import { mapClaudeEvent } from "./claude-events.ts";
|
|
6
|
+
import type { ResumeStore } from "./resume-store.ts";
|
|
7
|
+
|
|
8
|
+
export class ClaudeChildSession implements ChildSession {
|
|
9
|
+
private readonly proc: ChildProcess;
|
|
10
|
+
private readonly sessionKey: string;
|
|
11
|
+
private readonly resumeStore: ResumeStore;
|
|
12
|
+
private readonly handlers: Array<(e: ChildSessionEvent) => void> = [];
|
|
13
|
+
private disposed = false;
|
|
14
|
+
private initCaptured = false;
|
|
15
|
+
private turnResolve: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(proc: ChildProcess, sessionKey: string, resumeStore: ResumeStore) {
|
|
18
|
+
this.proc = proc;
|
|
19
|
+
this.sessionKey = sessionKey;
|
|
20
|
+
this.resumeStore = resumeStore;
|
|
21
|
+
const rl = createInterface({ input: proc.stdout! });
|
|
22
|
+
rl.on("line", (line) => this.onLine(line));
|
|
23
|
+
proc.on("close", () => { if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private onLine(line: string): void {
|
|
27
|
+
const ev = mapClaudeEvent(line);
|
|
28
|
+
if (!ev) return;
|
|
29
|
+
if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
|
|
30
|
+
this.initCaptured = true;
|
|
31
|
+
this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
|
|
32
|
+
}
|
|
33
|
+
if (ev.type === "turn_end" || ev.type === "error") {
|
|
34
|
+
if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
|
|
35
|
+
}
|
|
36
|
+
for (const h of this.handlers) h(ev);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async prompt(text: string): Promise<void> {
|
|
40
|
+
if (this.disposed) throw new Error("session disposed");
|
|
41
|
+
if (!this.proc.stdin) throw new Error("claude child has no stdin pipe");
|
|
42
|
+
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } }) + "\n";
|
|
43
|
+
return new Promise<void>((resolve) => {
|
|
44
|
+
this.turnResolve = resolve;
|
|
45
|
+
this.proc.stdin!.write(msg, () => { /* fire-and-forget; resolved on turn_end/close */ });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
subscribe(handler: (e: ChildSessionEvent) => void): () => void {
|
|
50
|
+
this.handlers.push(handler);
|
|
51
|
+
return () => {
|
|
52
|
+
const i = this.handlers.indexOf(handler);
|
|
53
|
+
if (i >= 0) this.handlers.splice(i, 1);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async abort(): Promise<void> {
|
|
58
|
+
if (this.disposed) return;
|
|
59
|
+
try { this.proc.kill("SIGTERM"); } catch { /* already dead */ }
|
|
60
|
+
this.disposed = true; // SIGTERM kills the child process; the session is irrecoverable
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dispose(): void {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
this.disposed = true;
|
|
66
|
+
try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
67
|
+
this.proc.stdout?.destroy();
|
|
68
|
+
try { this.proc.stdin?.end(); } catch { /* already closed */ }
|
|
69
|
+
this.proc.removeAllListeners();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isDisposed(): boolean {
|
|
73
|
+
return this.disposed;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/backend/hook-parity.ts — declared per-backend hook parity (SPEC-3 §2.3, §4.6).
|
|
2
|
+
// The chip is a static backend property, never inferred at spawn time.
|
|
3
|
+
|
|
4
|
+
export type HookState = "✓" | "~";
|
|
5
|
+
|
|
6
|
+
export interface BackendHookParity {
|
|
7
|
+
/** `todo` tool excluded from the child. */
|
|
8
|
+
todo: HookState;
|
|
9
|
+
/** memory-hydrate (3-scope) active in the child. */
|
|
10
|
+
memory: HookState;
|
|
11
|
+
/** vision: capability-aware. `✓` = full (describe_image fallback); `~` = pass-through only. */
|
|
12
|
+
vision: HookState;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Pi backend: full moat via loader injection + customTools (SPEC-2). */
|
|
16
|
+
export const PI_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "✓" };
|
|
17
|
+
|
|
18
|
+
/** CC backend: moat via prompt/flag translation. Vision has no describe_image fallback (`~`). */
|
|
19
|
+
export const CLAUDE_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "~" };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// src/backend/port.ts — single import surface for engine + views (SPEC-3 §3).
|
|
2
|
+
export type { BackendHookParity, HookState } from "./hook-parity.ts";
|
|
3
|
+
export { PI_HOOK_PARITY, CLAUDE_HOOK_PARITY } from "./hook-parity.ts";
|
|
4
|
+
export type { Backend, BackendVersionInfo } from "./registry.ts";
|
|
5
|
+
export { BackendRegistry } from "./registry.ts";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/backend/registry.ts — BackendRegistry + Backend descriptor (SPEC-3 §2.1).
|
|
2
|
+
import type { ChildSessionFactory } from "../engine/spawnSubagent.ts";
|
|
3
|
+
import type { BackendHookParity } from "./hook-parity.ts";
|
|
4
|
+
|
|
5
|
+
export interface BackendVersionInfo {
|
|
6
|
+
version: string;
|
|
7
|
+
schemaOk: boolean;
|
|
8
|
+
/** Flag support matrix probed at detect time (kebab-case flag → supported?). */
|
|
9
|
+
flagSupport: Record<string, boolean>;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Backend {
|
|
14
|
+
id: "pi" | "claude";
|
|
15
|
+
factory: ChildSessionFactory;
|
|
16
|
+
available: () => boolean;
|
|
17
|
+
versionInfo: () => BackendVersionInfo | null;
|
|
18
|
+
hookParity: BackendHookParity;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class BackendRegistry {
|
|
22
|
+
private readonly backends = new Map<string, Backend>();
|
|
23
|
+
private readonly order: string[] = [];
|
|
24
|
+
|
|
25
|
+
register(b: Backend): void {
|
|
26
|
+
if (!this.backends.has(b.id)) this.order.push(b.id);
|
|
27
|
+
this.backends.set(b.id, b);
|
|
28
|
+
}
|
|
29
|
+
get(id: string): Backend | undefined {
|
|
30
|
+
return this.backends.get(id);
|
|
31
|
+
}
|
|
32
|
+
/** Registration-order list — the data source for the Backends view + engine lookup. */
|
|
33
|
+
list(): Backend[] {
|
|
34
|
+
return this.order.map((id) => this.backends.get(id)!).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/backend/resume-store.ts — file-backed sessionKey → backendSessionId, per backend (SPEC-3 §2.4, §4.3).
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function rootDir(): string {
|
|
6
|
+
return process.env.FLEET_RESUME_ROOT ?? join(process.env.HOME ?? "/tmp", ".pi", "agent", "cache", "fleet-resume");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Per-backend JSON map: { [sessionKey]: backendSessionId }. */
|
|
10
|
+
function fileFor(backendId: string): string {
|
|
11
|
+
return join(rootDir(), `${backendId}.json`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readMap(backendId: string): Record<string, string> {
|
|
15
|
+
const f = fileFor(backendId);
|
|
16
|
+
if (!existsSync(f)) return {};
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(f, "utf8")) as Record<string, string>;
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeMap(backendId: string, m: Record<string, string>): void {
|
|
25
|
+
const dir = rootDir();
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
writeFileSync(fileFor(backendId), JSON.stringify(m, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ResumeStore {
|
|
31
|
+
get(backendId: string, sessionKey: string): string | null {
|
|
32
|
+
return readMap(backendId)[sessionKey] ?? null;
|
|
33
|
+
}
|
|
34
|
+
set(backendId: string, sessionKey: string, backendSessionId: string): void {
|
|
35
|
+
const m = readMap(backendId);
|
|
36
|
+
m[sessionKey] = backendSessionId;
|
|
37
|
+
writeMap(backendId, m);
|
|
38
|
+
}
|
|
39
|
+
clear(backendId: string, sessionKey: string): void {
|
|
40
|
+
const m = readMap(backendId);
|
|
41
|
+
delete m[sessionKey];
|
|
42
|
+
writeMap(backendId, m);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -12,6 +12,10 @@ export interface RunRecord {
|
|
|
12
12
|
startedAt: number;
|
|
13
13
|
endedAt?: number;
|
|
14
14
|
resultSummary?: string;
|
|
15
|
+
/** Backend-native session id for resume (SPEC-3). */
|
|
16
|
+
backendSessionId?: string | null;
|
|
17
|
+
/** The sessionKey whose resume this run belongs to (SPEC-3). */
|
|
18
|
+
sessionKey?: string | null;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
|
|
@@ -3,6 +3,7 @@ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
|
|
|
3
3
|
import type { FleetRunStatus, TodoSyncPort } from "../todo-sync/port.ts";
|
|
4
4
|
import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
5
5
|
import type { VisionPort } from "../vision/port.ts";
|
|
6
|
+
import type { BackendRegistry } from "../backend/port.ts";
|
|
6
7
|
import { genRunId, RunRegistry } from "./run-registry.ts";
|
|
7
8
|
import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
|
|
8
9
|
import type { SingleSlotLock } from "./concurrency-lock.ts";
|
|
@@ -25,6 +26,8 @@ export interface ChildSessionEvent {
|
|
|
25
26
|
content?: Array<{ type: string; text?: string }>;
|
|
26
27
|
usage?: { cost?: { total?: number } };
|
|
27
28
|
};
|
|
29
|
+
/** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
|
|
30
|
+
backendSessionId?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export interface ChildSession {
|
|
@@ -62,13 +65,22 @@ export interface SpawnOptions {
|
|
|
62
65
|
todoSync: TodoSyncPort;
|
|
63
66
|
runRegistry: RunRegistry;
|
|
64
67
|
lock: SingleSlotLock;
|
|
65
|
-
|
|
68
|
+
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
|
|
66
69
|
parentModel: { provider: string; id: string };
|
|
67
70
|
parentCwd: string;
|
|
68
71
|
memoryPort?: MemoryHydratePort;
|
|
69
72
|
visionPort?: VisionPort;
|
|
70
73
|
signal?: AbortSignal;
|
|
71
74
|
onEvent?: (e: ChildSessionEvent) => void;
|
|
75
|
+
/** SPEC-4: when set, this spawn is a lifecycle phase child. It links to this lifecycle todo
|
|
76
|
+
* (not creates a new one) and finishRun skips mark-done/revert — the lifecycle engine owns
|
|
77
|
+
* the lifecycle todo's status + progress block. */
|
|
78
|
+
lifecycleTodoId?: string;
|
|
79
|
+
/** SPEC-4: when set (lifecycle phase child), override the agent's `skills` frontmatter with
|
|
80
|
+
* the lifecycle's merged phase skill bundle (Q1=B) + route to the phase's resolved backend
|
|
81
|
+
* (Q4=C) instead of the agent's `backend`. */
|
|
82
|
+
skillsOverride?: string[];
|
|
83
|
+
backendOverride?: "pi" | "claude";
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
export interface SpawnResult {
|
|
@@ -107,12 +119,24 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
107
119
|
return fail(runId, startedAt, `agent '${opts.agent}' not in registry; available: ${available}`, opts.agent);
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
// SPEC-3: route via the backend registry; fail fast if the backend is missing/unavailable.
|
|
123
|
+
// SPEC-4: a lifecycle phase child may override the backend (Q4=C) + the skill bundle (Q1=B).
|
|
124
|
+
const backendId = opts.backendOverride ?? agentDef.backend;
|
|
125
|
+
const backend = opts.backendRegistry.get(backendId);
|
|
126
|
+
if (!backend || !backend.available()) {
|
|
127
|
+
const note = backend?.versionInfo()?.note ?? "not registered";
|
|
128
|
+
return fail(runId, startedAt, `backend '${backendId}' unavailable: ${note}`, opts.agent);
|
|
129
|
+
}
|
|
130
|
+
// SPEC-4: when a lifecycle provides a skills override, clone the agentDef so the factory's
|
|
131
|
+
// skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
|
|
132
|
+
const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
|
|
133
|
+
|
|
110
134
|
// resolve model
|
|
111
135
|
const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
|
|
112
136
|
|
|
113
137
|
// child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
|
|
114
138
|
// downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
|
|
115
|
-
const tools =
|
|
139
|
+
const tools = childAgent.tools ?? PI_DEFAULT_TOOLS;
|
|
116
140
|
const memoryPort = opts.memoryPort ?? NOOP_MEMORY_PORT;
|
|
117
141
|
const visionPort = opts.visionPort ?? NOOP_VISION_PORT;
|
|
118
142
|
|
|
@@ -128,7 +152,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
128
152
|
try {
|
|
129
153
|
const link = await opts.todoSync.linkOrCreateRunTodo({
|
|
130
154
|
runId, agent: agentDef.name, task: opts.task,
|
|
131
|
-
todoId: opts.todoId, track: track && agentDef.todoSync,
|
|
155
|
+
todoId: opts.lifecycleTodoId ?? opts.todoId, track: track && agentDef.todoSync,
|
|
132
156
|
});
|
|
133
157
|
todoId = link.todoId;
|
|
134
158
|
priorStatus = link.priorStatus;
|
|
@@ -138,15 +162,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
138
162
|
}
|
|
139
163
|
|
|
140
164
|
// spawn child
|
|
141
|
-
const { session } = await
|
|
165
|
+
const { session } = await backend.factory.create({
|
|
142
166
|
cwd: opts.parentCwd,
|
|
143
167
|
model,
|
|
144
|
-
thinkingLevel:
|
|
168
|
+
thinkingLevel: childAgent.thinkingLevel,
|
|
145
169
|
tools,
|
|
146
|
-
rolePrompt:
|
|
147
|
-
skills:
|
|
170
|
+
rolePrompt: childAgent.rolePrompt,
|
|
171
|
+
skills: childAgent.skills ?? [],
|
|
148
172
|
task: opts.task,
|
|
149
|
-
agent:
|
|
173
|
+
agent: childAgent,
|
|
150
174
|
memoryPort,
|
|
151
175
|
visionPort,
|
|
152
176
|
});
|
|
@@ -160,7 +184,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
160
184
|
opts.signal?.addEventListener("abort", onSignalAbort);
|
|
161
185
|
|
|
162
186
|
const unsub = session.subscribe((e) => {
|
|
163
|
-
if (e.type === "
|
|
187
|
+
if (e.type === "session_init" && e.backendSessionId) {
|
|
188
|
+
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
189
|
+
} else if (e.type === "turn_end") {
|
|
164
190
|
if (budget.consume()) void session.abort();
|
|
165
191
|
} else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
166
192
|
const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
|
|
@@ -217,15 +243,18 @@ async function finishRun(
|
|
|
217
243
|
): Promise<SpawnResult> {
|
|
218
244
|
const endedAt = Date.now();
|
|
219
245
|
opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120) });
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
246
|
+
// SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
|
|
247
|
+
// engine owns the lifecycle todo's status + progress block (Q7=C).
|
|
248
|
+
if (!opts.lifecycleTodoId) {
|
|
249
|
+
try {
|
|
250
|
+
if (status === "completed") {
|
|
251
|
+
await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
|
|
252
|
+
} else {
|
|
253
|
+
await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
|
|
226
257
|
}
|
|
227
|
-
} catch {
|
|
228
|
-
// swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
|
|
229
258
|
}
|
|
230
259
|
return {
|
|
231
260
|
status, finalText, runId, todoId, agent: agentName, model,
|