@getpipher/armory-fleet 0.16.0 → 1.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.
@@ -0,0 +1,32 @@
1
+ // src/panel/live-timeline.ts
2
+ // SPEC-6-4 — tail-follow state for the live timeline overlay. Pure logic (unit-tested);
3
+ // the panel owns the SelectList and consults this on forwarded keys + live appends.
4
+ export class LiveTimelineState {
5
+ /** 0-based cursor into the RENDERED (message/tool-filtered) event list. */
6
+ index = 0;
7
+ /** True while the cursor rides the newest row (live appends move the view). */
8
+ pinned = true;
9
+
10
+ /** Handle a forwarded scroll key. Returns true when the view must re-render. */
11
+ onKey(key: "up" | "down", total: number): boolean {
12
+ if (total === 0) return false;
13
+ if (key === "up") {
14
+ if (this.index <= 0) return false;
15
+ this.index--;
16
+ this.pinned = false;
17
+ return true;
18
+ }
19
+ // down
20
+ if (this.index >= total - 1) return false;
21
+ this.index++;
22
+ this.pinned = this.index === total - 1;
23
+ return true;
24
+ }
25
+
26
+ /** A new event arrived (list now has `total` rendered rows). Returns the cursor to restore:
27
+ * the newest row while pinned, unchanged otherwise. */
28
+ append(total: number): number {
29
+ if (this.pinned) this.index = total - 1;
30
+ return this.index;
31
+ }
32
+ }
@@ -0,0 +1,105 @@
1
+ // src/rpc/event-bus.ts
2
+ // SPEC-6-4 — FleetEventBus: translates RunLog/RunJournal appends into the public fleet:*
3
+ // taxonomy. THE FROZEN SURFACE LIVES HERE: channel names + envelope shapes are pinned by
4
+ // test/fleet-event-bus.test.mts — change them and every consumer breaks loudly.
5
+ //
6
+ // Seq spaces: one per source store (RunLog event order / journal event order), per run.
7
+ // Replay (RpcServer) reconstructs identical seqs by walking the same orders — consumers
8
+ // dedupe live-vs-replay by (channel, runId, seq).
9
+ import type { RunJournal, JournalEvent } from "../runtime/run-journal.ts";
10
+ import type { RunLog, RunLogEvent } from "../runtime/run-log.ts";
11
+
12
+ export type FleetChannel =
13
+ | "fleet:run:started" | "fleet:run:ended"
14
+ | "fleet:phase:started" | "fleet:phase:completed" | "fleet:phase:failed"
15
+ | "fleet:child:message" | "fleet:child:tool";
16
+
17
+ export interface FleetEnvelope {
18
+ runId: string;
19
+ seq: number;
20
+ /** Publish time (Date.now() at translation) — not the event's own timestamp. */
21
+ ts: number;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ export interface FleetEventBusDeps {
26
+ runLog: Pick<RunLog, "subscribe">;
27
+ journal: Pick<RunJournal, "subscribe">;
28
+ /** Transport seam. In-process = (c, p) => pi.events.emit(c, p). A future external bridge
29
+ * re-implements ONLY this — the taxonomy above is its wire format. */
30
+ emit: (channel: FleetChannel, payload: FleetEnvelope) => void;
31
+ }
32
+
33
+ interface MetaLike { startedAt: number }
34
+
35
+ export class FleetEventBus {
36
+ private readonly runSeq = new Map<string, number>();
37
+ private readonly phaseSeq = new Map<string, number>();
38
+ private readonly startedAt = new Map<string, number>();
39
+ private readonly unsubs: Array<() => void> = [];
40
+
41
+ constructor(private readonly deps: FleetEventBusDeps) {
42
+ this.unsubs.push(
43
+ deps.runLog.subscribe((runId, event) => this.safe(() => this.onRunLogEvent(runId, event))),
44
+ deps.journal.subscribe((runId, event) => this.safe(() => this.onJournalEvent(runId, event))),
45
+ );
46
+ }
47
+
48
+ /** Unsubscribe from both stores. Call from session_shutdown. */
49
+ dispose(): void {
50
+ for (const u of this.unsubs) u();
51
+ this.unsubs.length = 0;
52
+ }
53
+
54
+ /** A bus failure must never break a run's append path. */
55
+ private safe(fn: () => void): void {
56
+ try { fn(); } catch { /* swallow: telemetry must not kill the product */ }
57
+ }
58
+
59
+ private next(map: Map<string, number>, runId: string): number {
60
+ const n = (map.get(runId) ?? 0) + 1;
61
+ map.set(runId, n);
62
+ return n;
63
+ }
64
+
65
+ private publish(channel: FleetChannel, runId: string, seq: number, payload: Record<string, unknown>): void {
66
+ this.deps.emit(channel, { runId, seq, ts: Date.now(), ...payload });
67
+ }
68
+
69
+ private onRunLogEvent(runId: string, e: RunLogEvent): void {
70
+ const seq = this.next(this.runSeq, runId);
71
+ if (e.type === "run:meta") {
72
+ this.startedAt.set(runId, e.startedAt);
73
+ this.publish("fleet:run:started", runId, seq, {
74
+ agent: e.agent, model: e.model, cwd: e.cwd, sessionCwd: e.sessionCwd,
75
+ mode: e.mode ?? "foreground", task: e.task,
76
+ });
77
+ } else if (e.type === "message") {
78
+ this.publish("fleet:child:message", runId, seq, { role: e.role, text: e.text });
79
+ } else if (e.type === "tool") {
80
+ this.publish("fleet:child:tool", runId, seq, { toolName: e.toolName, args: e.args, result: e.result, isError: e.isError });
81
+ } else if (e.type === "run:ended") {
82
+ const start = this.startedAt.get(runId);
83
+ this.publish("fleet:run:ended", runId, seq, {
84
+ status: e.status,
85
+ ...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
86
+ ...(e.error !== undefined ? { error: e.error } : {}),
87
+ ...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
88
+ ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}),
89
+ ...(start !== undefined ? { durationMs: e.endedAt - start } : {}),
90
+ });
91
+ }
92
+ }
93
+
94
+ private onJournalEvent(runId: string, e: JournalEvent): void {
95
+ // Only the phase tier is public; run:started/completed/aborted/checkpoint/agent:*/helper:*
96
+ // stay internal (run-level events come from RunLog, which every spawn writes).
97
+ if (e.type === "phase:started") {
98
+ this.publish("fleet:phase:started", runId, this.next(this.phaseSeq, runId), { phase: e.phase });
99
+ } else if (e.type === "phase:completed") {
100
+ this.publish("fleet:phase:completed", runId, this.next(this.phaseSeq, runId), { phase: e.phase, summary: e.summary, paths: e.paths });
101
+ } else if (e.type === "phase:failed") {
102
+ this.publish("fleet:phase:failed", runId, this.next(this.phaseSeq, runId), { phase: e.phase, error: e.error });
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,284 @@
1
+ // src/rpc/rpc-server.ts
2
+ // SPEC-6-4 — the fleet:rpc verb surface. Frozen: verb names, param contracts, reply envelope
3
+ // { id, ok, data | error{code,message} }, and the error-code enum — all pinned by
4
+ // test/rpc-server.test.mts. handle() NEVER throws and replies EXACTLY once per request.
5
+ import { genRunId } from "../engine/run-registry.ts";
6
+ import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
7
+ import type { RunLog } from "../runtime/run-log.ts";
8
+ import type { RunJournal } from "../runtime/run-journal.ts";
9
+ import { resolveDispatchCwd } from "../tools/subagent.ts";
10
+
11
+ export type RpcErrorCode =
12
+ | "E-CONTROL-DISABLED" | "E-RUN-NOT-FOUND" | "E-RUN-FINISHED" | "E-BAD-VERB"
13
+ | "E-BAD-PARAMS" | "E-STEER-UNSUPPORTED" | "E-INTERNAL";
14
+
15
+ export interface RpcRequest { id: string; verb: string; params?: unknown }
16
+ export type RpcReply =
17
+ | { id: string; ok: true; data: unknown }
18
+ | { id: string; ok: false; error: { code: RpcErrorCode; message: string } };
19
+
20
+ /** SPEC-6-4 gate: ON unless ARMORY_FLEET_RPC_CONTROL is "0"/"false" (case-insensitive).
21
+ * Read-only verbs (observe/status) ignore this. Honest threat model: in-process extensions
22
+ * already have full system access via pi itself — the gate guards accidents, not adversaries. */
23
+ export function rpcControlEnabled(env: string | undefined = process.env.ARMORY_FLEET_RPC_CONTROL): boolean {
24
+ const v = (env ?? "").trim().toLowerCase();
25
+ return !(v === "0" || v === "false");
26
+ }
27
+
28
+ export interface RpcRunSummary {
29
+ runId: string; agent: string; model: string; status: string; startedAt: number;
30
+ endedAt?: number; task: string; cwd?: string; resultSummary?: string; tokenTotal?: number; sessionKey?: string;
31
+ }
32
+
33
+ export interface RpcServerDeps {
34
+ runRegistry: Pick<RunRegistry, "get" | "list">;
35
+ runLog: Pick<RunLog, "replay">;
36
+ journal: Pick<RunJournal, "replay">;
37
+ parentCwd: string;
38
+ hasAsyncRunner: boolean;
39
+ /** Detached spawn: index.ts builds the real spawnSubagent invocation (foreground or bg routing).
40
+ * Never throws — runtime failures land via the registry + RunLog journal (spawnSubagent's own
41
+ * fail path journals run:ended), so the caller's { runId } always resolves to a real run. */
42
+ spawn: (params: Record<string, unknown>, runId: string) => void;
43
+ /** #83: schedule registration (scheduler.register under the hood). Throws on an invalid
44
+ * expression (surfaced as E-BAD-PARAMS); absent = scheduling not configured in this session. */
45
+ schedule?: (spec: Record<string, unknown>) => { scheduleId: string; nextFire: string | null };
46
+ }
47
+
48
+ const LIST_CAP = 25;
49
+ const TASK_SUMMARY_CAP = 80;
50
+
51
+ function summarize(r: RunRecord): RpcRunSummary {
52
+ const task = r.task.length > TASK_SUMMARY_CAP ? r.task.slice(0, TASK_SUMMARY_CAP - 1) + "…" : r.task;
53
+ return {
54
+ runId: r.runId, agent: r.agent, model: r.model, status: r.status, startedAt: r.startedAt,
55
+ ...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
56
+ task, ...(r.cwd ? { cwd: r.cwd } : {}),
57
+ ...(r.resultSummary !== undefined ? { resultSummary: r.resultSummary } : {}),
58
+ ...(r.tokenTotal !== undefined ? { tokenTotal: r.tokenTotal } : {}),
59
+ ...(r.sessionKey ? { sessionKey: r.sessionKey } : {}),
60
+ };
61
+ }
62
+
63
+ export class RpcServer {
64
+ constructor(
65
+ private readonly deps: RpcServerDeps,
66
+ private readonly controlEnabled: () => boolean = rpcControlEnabled,
67
+ ) {}
68
+
69
+ /** Returns the reply, or null for a malformed request with no usable id (caller drops).
70
+ * Never throws — a handler exception becomes E-INTERNAL. */
71
+ async handle(req: unknown): Promise<RpcReply | null> {
72
+ const id = requestId(req);
73
+ try {
74
+ return await this.dispatch(req, id);
75
+ } catch (e) {
76
+ if (!id) return null;
77
+ return { id, ok: false, error: { code: "E-INTERNAL", message: `unexpected rpc failure: ${(e as Error).message}` } };
78
+ }
79
+ }
80
+
81
+ private async dispatch(req: unknown, id: string | null): Promise<RpcReply | null> {
82
+ if (!req || typeof req !== "object" || !id) return null;
83
+ const { verb, params } = req as Record<string, unknown>;
84
+ if (typeof verb !== "string") return this.err(id, "E-BAD-VERB", "missing verb");
85
+ const gated = this.controlEnabled();
86
+ switch (verb) {
87
+ case "spawn": return gated ? this.spawnVerb(id, params) : this.controlDisabled(id);
88
+ case "steer": return gated ? this.steerVerb(id, params) : this.controlDisabled(id);
89
+ case "abort": return gated ? this.abortVerb(id, params) : this.controlDisabled(id);
90
+ case "schedule": return gated ? this.scheduleVerb(id, params) : this.controlDisabled(id);
91
+ case "observe": return this.observeVerb(id, params);
92
+ case "status": return this.statusVerb(id, params);
93
+ default: return this.err(id, "E-BAD-VERB", `unknown verb '${verb}' (known: spawn, steer, observe, abort, status, schedule)`);
94
+ }
95
+ }
96
+
97
+ private controlDisabled(id: string): RpcReply {
98
+ return this.err(id, "E-CONTROL-DISABLED", "fleet rpc control is disabled (ARMORY_FLEET_RPC_CONTROL is set to off; remove it or set it to 1 to enable spawn/steer/abort/schedule)");
99
+ }
100
+
101
+ private err(id: string, code: RpcErrorCode, message: string): RpcReply {
102
+ return { id, ok: false, error: { code, message } };
103
+ }
104
+
105
+ private obj(params: unknown): Record<string, unknown> | null {
106
+ return params && typeof params === "object" ? params as Record<string, unknown> : null;
107
+ }
108
+
109
+ private spawnVerb(id: string, params: unknown): RpcReply {
110
+ const p = this.obj(params);
111
+ if (!p) return this.err(id, "E-BAD-PARAMS", "spawn requires params: { agent, task, ... }");
112
+ if (typeof p.agent !== "string" || !p.agent) return this.err(id, "E-BAD-PARAMS", "params.agent must be a non-empty string");
113
+ if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
114
+ if (p.lifecycle !== undefined && (typeof p.lifecycle !== "string" || !p.lifecycle)) return this.err(id, "E-BAD-PARAMS", "params.lifecycle must be a non-empty string when set (#83)");
115
+ if (p.schedule !== undefined) return this.err(id, "E-BAD-PARAMS", "params.schedule is not a spawn param — schedules run lifecycles, not single delegates; use the 'schedule' verb (#83)");
116
+ if (p.modelFallback !== undefined && (typeof p.modelFallback !== "string" || !p.modelFallback)) return this.err(id, "E-BAD-PARAMS", "params.modelFallback must be a non-empty string when set (#83)");
117
+ if (p.cwd !== undefined && (typeof p.cwd !== "string" || p.cwd === "")) return this.err(id, "E-BAD-PARAMS", "params.cwd must be a non-empty string when set");
118
+ if (p.cwd !== undefined) {
119
+ const { error } = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
120
+ if (error) return this.err(id, "E-BAD-PARAMS", error);
121
+ }
122
+ if (p.background !== undefined && typeof p.background !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.background must be a boolean");
123
+ if (p.background && !this.deps.hasAsyncRunner) return this.err(id, "E-BAD-PARAMS", "background runs not configured in this session (asyncRunner missing)");
124
+ if (p.isolation !== undefined && p.isolation !== "worktree" && p.isolation !== "none" && p.isolation !== "auto") {
125
+ return this.err(id, "E-BAD-PARAMS", "params.isolation must be 'worktree' | 'none' | 'auto'");
126
+ }
127
+ if (p.maxTurns !== undefined && (typeof p.maxTurns !== "number" || !Number.isInteger(p.maxTurns) || p.maxTurns < 1)) {
128
+ return this.err(id, "E-BAD-PARAMS", "params.maxTurns must be a positive integer");
129
+ }
130
+ if (p.readOnly !== undefined && typeof p.readOnly !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.readOnly must be a boolean");
131
+ if (p.track !== undefined && typeof p.track !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.track must be a boolean");
132
+ if (p.todoId !== undefined && typeof p.todoId !== "string") return this.err(id, "E-BAD-PARAMS", "params.todoId must be a string");
133
+ if (p.model !== undefined && (typeof p.model !== "string" || !p.model)) return this.err(id, "E-BAD-PARAMS", "params.model must be a non-empty string when set");
134
+ if (p.skills !== undefined && (!Array.isArray(p.skills) || !p.skills.every((s) => typeof s === "string"))) {
135
+ return this.err(id, "E-BAD-PARAMS", "params.skills must be an array of strings");
136
+ }
137
+ const runId = genRunId();
138
+ this.deps.spawn(p, runId);
139
+ return { id, ok: true, data: { runId } };
140
+ }
141
+
142
+ /** #83 D4: register a recurring lifecycle run. Reply shape { scheduleId, nextFire } — schedules
143
+ * are NOT runs, so no runId (spawn's uniform { runId } contract stays unbranched). */
144
+ private scheduleVerb(id: string, params: unknown): RpcReply {
145
+ const p = this.obj(params);
146
+ if (!p) return this.err(id, "E-BAD-PARAMS", "schedule requires params: { task, expression, ... }");
147
+ if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
148
+ if (typeof p.expression !== "string" || !p.expression) return this.err(id, "E-BAD-PARAMS", "params.expression must be a non-empty string (cron or interval, e.g. '*/5 * * * *' or '30m')");
149
+ if (p.lifecycle !== undefined && (typeof p.lifecycle !== "string" || !p.lifecycle)) return this.err(id, "E-BAD-PARAMS", "params.lifecycle must be a non-empty string when set");
150
+ if (p.auto !== undefined && typeof p.auto !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.auto must be a boolean");
151
+ if (p.isolation !== undefined && p.isolation !== "worktree" && p.isolation !== "none" && p.isolation !== "auto") {
152
+ return this.err(id, "E-BAD-PARAMS", "params.isolation must be 'worktree' | 'none' | 'auto'");
153
+ }
154
+ if (p.cwd !== undefined && (typeof p.cwd !== "string" || p.cwd === "")) return this.err(id, "E-BAD-PARAMS", "params.cwd must be a non-empty string when set");
155
+ let cwd: string | undefined;
156
+ if (p.cwd !== undefined) {
157
+ const resolved = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
158
+ if (resolved.error) return this.err(id, "E-BAD-PARAMS", resolved.error);
159
+ cwd = resolved.cwd;
160
+ }
161
+ if (!this.deps.schedule) {
162
+ return this.err(id, "E-BAD-PARAMS", "scheduling not configured in this session (scheduler missing)");
163
+ }
164
+ try {
165
+ const out = this.deps.schedule({
166
+ task: p.task, expression: p.expression,
167
+ ...(p.lifecycle !== undefined ? { lifecycle: p.lifecycle } : {}),
168
+ ...(p.auto !== undefined ? { auto: p.auto } : {}),
169
+ ...(p.isolation !== undefined ? { isolation: p.isolation } : {}),
170
+ ...(cwd !== undefined ? { cwd } : {}),
171
+ });
172
+ return { id, ok: true, data: { scheduleId: out.scheduleId, nextFire: out.nextFire } };
173
+ } catch (e) {
174
+ return this.err(id, "E-BAD-PARAMS", (e as Error).message || "schedule registration failed");
175
+ }
176
+ }
177
+
178
+ private statusVerb(id: string, params: unknown): RpcReply {
179
+ const p = this.obj(params) ?? {};
180
+ if (p.runId !== undefined) {
181
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
182
+ const rec = this.deps.runRegistry.get(p.runId);
183
+ if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry (finished runs older than the session are not listed)`);
184
+ return { id, ok: true, data: { runs: [summarize(rec)] } };
185
+ }
186
+ const runs = this.deps.runRegistry.list().slice(0, LIST_CAP).map(summarize);
187
+ return { id, ok: true, data: { runs } };
188
+ }
189
+
190
+ private observeVerb(id: string, params: unknown): RpcReply {
191
+ const p = this.obj(params);
192
+ if (!p) return this.err(id, "E-BAD-PARAMS", "observe requires params: { runId, tier? }");
193
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
194
+ const tier = p.tier ?? "both";
195
+ if (tier !== "lifecycle" && tier !== "child" && tier !== "both") {
196
+ return this.err(id, "E-BAD-PARAMS", "params.tier must be 'lifecycle' | 'child' | 'both'");
197
+ }
198
+ const logEvents = this.deps.runLog.replay(p.runId);
199
+ const journalEvents = this.deps.journal.replay(p.runId);
200
+ if (logEvents.length === 0 && journalEvents.length === 0) {
201
+ return this.err(id, "E-RUN-NOT-FOUND", `no journaled run '${p.runId}'`);
202
+ }
203
+ const events: Array<{ channel: string; payload: Record<string, unknown> }> = [];
204
+ // Seq = position in the FULL store event list (index + 1) — identical to the live bus's
205
+ // per-store dense counting, so the (channel, runId, seq) dedupe contract holds across
206
+ // the live→replay handoff. Tier filters decide WHICH entries emit, not how they count.
207
+ // Journal exception: the live bus increments phaseSeq ONLY on the three phase types, so
208
+ // replay filters to phases BEFORE counting — real lifecycle runs bookend the journal with
209
+ // run:started/completed, and position-in-full-list would overshoot by the bookends.
210
+ const phases = journalEvents.filter((e) => e.type === "phase:started" || e.type === "phase:completed" || e.type === "phase:failed");
211
+ if (tier === "lifecycle" || tier === "both") {
212
+ logEvents.forEach((e, i) => {
213
+ if (e.type === "run:meta") {
214
+ events.push({ channel: "fleet:run:started", payload: { seq: i + 1, agent: e.agent, model: e.model, cwd: e.cwd, sessionCwd: e.sessionCwd, mode: e.mode ?? "foreground", task: e.task, ts: e.startedAt } });
215
+ } else if (e.type === "run:ended") {
216
+ events.push({
217
+ channel: "fleet:run:ended",
218
+ payload: { seq: i + 1, status: e.status, ts: e.endedAt,
219
+ ...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
220
+ ...(e.error !== undefined ? { error: e.error } : {}),
221
+ ...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
222
+ ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}) },
223
+ });
224
+ }
225
+ });
226
+ phases.forEach((e, i) => {
227
+ if (e.type === "phase:started") events.push({ channel: "fleet:phase:started", payload: { seq: i + 1, phase: e.phase, ts: e.ts } });
228
+ else if (e.type === "phase:completed") events.push({ channel: "fleet:phase:completed", payload: { seq: i + 1, phase: e.phase, summary: e.summary, paths: e.paths, ts: e.ts } });
229
+ else if (e.type === "phase:failed") events.push({ channel: "fleet:phase:failed", payload: { seq: i + 1, phase: e.phase, error: e.error, ts: e.ts } });
230
+ });
231
+ }
232
+ if (tier === "child" || tier === "both") {
233
+ logEvents.forEach((e, i) => {
234
+ if (e.type === "message") events.push({ channel: "fleet:child:message", payload: { seq: i + 1, role: e.role, text: e.text } });
235
+ else if (e.type === "tool") events.push({ channel: "fleet:child:tool", payload: { seq: i + 1, toolName: e.toolName, args: e.args, result: e.result, isError: e.isError } });
236
+ });
237
+ }
238
+ return { id, ok: true, data: { runId: p.runId, tier, events } };
239
+ }
240
+
241
+ private async steerVerb(id: string, params: unknown): Promise<RpcReply> {
242
+ const p = this.obj(params);
243
+ if (!p) return this.err(id, "E-BAD-PARAMS", "steer requires params: { runId, message }");
244
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
245
+ if (typeof p.message !== "string" || !p.message) return this.err(id, "E-BAD-PARAMS", "params.message must be a non-empty string");
246
+ const rec = this.deps.runRegistry.get(p.runId);
247
+ if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
248
+ const session = rec.session;
249
+ if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
250
+ if (!session.supportsSteer) return this.err(id, "E-STEER-UNSUPPORTED", `run '${p.runId}' backend has no steer support (claude children)`);
251
+ try {
252
+ await session.steer(p.message);
253
+ } catch (e) {
254
+ const msg = (e as Error).message ?? "steer failed";
255
+ if (msg.includes("not supported")) return this.err(id, "E-STEER-UNSUPPORTED", msg);
256
+ return this.err(id, "E-INTERNAL", `steer failed: ${msg}`);
257
+ }
258
+ return { id, ok: true, data: { steered: true } };
259
+ }
260
+
261
+ private async abortVerb(id: string, params: unknown): Promise<RpcReply> {
262
+ const p = this.obj(params);
263
+ if (!p) return this.err(id, "E-BAD-PARAMS", "abort requires params: { runId }");
264
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
265
+ const rec = this.deps.runRegistry.get(p.runId);
266
+ if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
267
+ const session = rec.session;
268
+ if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
269
+ try {
270
+ await session.abort();
271
+ } catch (e) {
272
+ const msg = (e as Error).message ?? "abort failed";
273
+ if (msg.includes("already")) return this.err(id, "E-RUN-FINISHED", msg);
274
+ return this.err(id, "E-INTERNAL", `abort failed: ${msg}`);
275
+ }
276
+ return { id, ok: true, data: { aborted: true } };
277
+ }
278
+ }
279
+
280
+ function requestId(req: unknown): string | null {
281
+ if (!req || typeof req !== "object") return null;
282
+ const id = (req as Record<string, unknown>).id;
283
+ return typeof id === "string" && id !== "" ? id : null;
284
+ }
@@ -26,9 +26,14 @@ export interface RunLifecycleOpts {
26
26
  worktreePath?: string;
27
27
  branch?: string;
28
28
  mode: "auto" | "checkpointed";
29
+ /** SPEC-6-4: origin threading to the bg lifecycle spawn adapter (→ spawnSubagent mode → run:meta). */
30
+ fleetMode?: "background" | "scheduled";
29
31
  /** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
30
32
  * Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
31
33
  entryCwd?: string;
34
+ /** #83: per-run fallback model for the phase-spawn retry wrapper (wins over the host's
35
+ * global default). Undefined = use the host default (back-compat). */
36
+ modelFallback?: string;
32
37
  }
33
38
 
34
39
  export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
@@ -57,8 +62,16 @@ export interface RunBackgroundOpts {
57
62
  deps: AsyncRunnerDeps;
58
63
  lifecycle: string;
59
64
  mode: "auto" | "checkpointed";
65
+ /** SPEC-6-4: dispatch origin for fleet:run:started `mode`. "scheduled" when fired by the scheduler. */
66
+ origin?: "background" | "scheduled";
67
+ /** SPEC-6-4: pre-minted runId (RPC spawn replies with the id BEFORE the detached bg run starts).
68
+ * Absent → the runner mints via deps.genRunId() exactly as before. */
69
+ runId?: string;
60
70
  /** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
61
71
  isolation?: Isolation;
72
+ /** #83: per-run fallback model — forwarded to the runLifecycle adapter so its phase-spawn
73
+ * retry wrapper prefers this over the host's global default. Undefined = host default. */
74
+ modelFallback?: string;
62
75
  /** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
63
76
  * isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
64
77
  * and in-place runs pass it as the lifecycle entryCwd. */
@@ -113,7 +126,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
113
126
  deps.journal.append(runId, ev0);
114
127
  emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
115
128
 
116
- const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd });
129
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd, fleetMode: opts.origin ?? "background", ...(opts.modelFallback ? { modelFallback: opts.modelFallback } : {}) });
117
130
 
118
131
  if (res.status === "completed") {
119
132
  if (isolated) {
@@ -158,7 +171,7 @@ function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgr
158
171
  if (!worktree.isGitRepo()) {
159
172
  return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
160
173
  }
161
- const runId = opts.deps.genRunId();
174
+ const runId = opts.runId ?? opts.deps.genRunId();
162
175
  const baseRef = "HEAD";
163
176
  let wt: { path: string; branch: string };
164
177
  try {
@@ -180,7 +193,7 @@ function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackground
180
193
  inPlaceFallbackWarned = true;
181
194
  opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
182
195
  }
183
- const runId = opts.deps.genRunId();
196
+ const runId = opts.runId ?? opts.deps.genRunId();
184
197
  runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
185
198
  return { runId, status: "background" };
186
199
  }
@@ -190,7 +203,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
190
203
  const isolation = opts.isolation ?? "auto";
191
204
  if (isolation === "worktree") return runBackgroundIsolated(task, opts);
192
205
  if (isolation === "none") {
193
- const runId = opts.deps.genRunId();
206
+ const runId = opts.runId ?? opts.deps.genRunId();
194
207
  runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
195
208
  return { runId, status: "background" };
196
209
  }
@@ -25,9 +25,20 @@ export class RunJournal {
25
25
  return join(this.dir, `${runId}.jsonl`);
26
26
  }
27
27
 
28
+ /** SPEC-6-4: append fan-out (FleetEventBus phase tier). Same contract as RunLog.subscribe. */
29
+ private readonly subscribers = new Set<(runId: string, event: JournalEvent) => void>();
30
+
31
+ subscribe(fn: (runId: string, event: JournalEvent) => void): () => void {
32
+ this.subscribers.add(fn);
33
+ return () => { this.subscribers.delete(fn); };
34
+ }
35
+
28
36
  append(runId: string, event: JournalEvent): void {
29
37
  mkdirSync(this.dir, { recursive: true });
30
38
  appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
39
+ for (const fn of this.subscribers) {
40
+ try { fn(runId, event); } catch { /* a faulty subscriber must not fail the append or others */ }
41
+ }
31
42
  }
32
43
 
33
44
  replay(runId: string): JournalEvent[] {
@@ -17,6 +17,8 @@ export interface RunMetaEvent {
17
17
  cwd?: string;
18
18
  /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
19
19
  sessionCwd?: string;
20
+ /** SPEC-6-4: dispatch origin — fleet:run:started `mode`. Default "foreground". */
21
+ mode?: "foreground" | "background" | "scheduled" | "workflow";
20
22
  }
21
23
  export interface MessageEvent {
22
24
  type: "message"; role: string; text: string;
@@ -77,12 +79,25 @@ export class RunLog {
77
79
 
78
80
  private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
79
81
 
82
+ /** SPEC-6-4: append fan-out (the FleetEventBus + live overlay subscribe). Fired synchronously
83
+ * after a successful write, in append order. A throwing subscriber never fails the append. */
84
+ private readonly subscribers = new Set<(runId: string, event: RunLogEvent) => void>();
85
+
86
+ subscribe(fn: (runId: string, event: RunLogEvent) => void): () => void {
87
+ this.subscribers.add(fn);
88
+ return () => { this.subscribers.delete(fn); };
89
+ }
90
+
80
91
  append(runId: string, event: RunLogEvent): void {
81
92
  try {
82
93
  mkdirSync(this.dir, { recursive: true });
83
94
  appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
84
95
  } catch {
85
96
  // best-effort: the run is the product; the journal is the index. Never fail the run.
97
+ return;
98
+ }
99
+ for (const fn of this.subscribers) {
100
+ try { fn(runId, event); } catch { /* a faulty subscriber must not fail the append or others */ }
86
101
  }
87
102
  }
88
103
 
@@ -0,0 +1,103 @@
1
+ // src/settings/fleet-settings.ts
2
+ // #78: fleet-dir settings.json — the settings home for fleet-wide defaults that
3
+ // aren't env-shaped (sibling of tiers.json in the same global+project scheme).
4
+ //
5
+ // Locations:
6
+ // global: ~/.pi/agent/fleet/settings.json
7
+ // project: <cwd>/.pi/fleet/settings.json (project wins per-field)
8
+ //
9
+ // Design rules:
10
+ // - Absent files are normal (empty settings, no warning).
11
+ // - Present-but-invalid content produces ACTIONABLE warnings (file + field + bad
12
+ // value) and drops the field — never a silent swallow (the TierStore ENOENT
13
+ // lesson: silent loaders make wrong docs doubly dangerous).
14
+ // - The schema is intentionally small and additive; unknown keys warn so typos
15
+ // surface instead of no-oping.
16
+ import { readFileSync } from "node:fs";
17
+ import type { ThinkingLevel } from "../registry/frontmatter.ts";
18
+
19
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
20
+
21
+ export function isThinkingLevel(v: unknown): v is ThinkingLevel {
22
+ return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v);
23
+ }
24
+
25
+ /** Fleet-wide defaults. Intentionally additive — new fields land here. */
26
+ export interface FleetSettings {
27
+ /** #78: applied to every subagent whose frontmatter does NOT pin `thinkingLevel`.
28
+ * Precedence: agent.thinkingLevel > this > the backend/session default. */
29
+ defaultSubagentThinking?: ThinkingLevel;
30
+ }
31
+
32
+ export interface FleetSettingsResult {
33
+ settings: FleetSettings;
34
+ /** Actionable, source-labeled warnings (never empty-string; always name the file). */
35
+ warnings: string[];
36
+ }
37
+
38
+ /** Parse one settings file's content. `label` names the file in warnings. */
39
+ export function parseFleetSettings(json: string, label = "settings.json"): FleetSettingsResult {
40
+ const warnings: string[] = [];
41
+ let raw: unknown;
42
+ try {
43
+ raw = JSON.parse(json);
44
+ } catch (e) {
45
+ return { settings: {}, warnings: [`${label}: invalid JSON (${(e as Error).message}) — fleet settings from this file ignored`] };
46
+ }
47
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
48
+ return { settings: {}, warnings: [`${label}: expected a JSON object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw} — fleet settings from this file ignored`] };
49
+ }
50
+ const obj = raw as Record<string, unknown>;
51
+ const settings: FleetSettings = {};
52
+
53
+ const thinking = obj["defaultSubagentThinking"];
54
+ if (thinking !== undefined) {
55
+ if (isThinkingLevel(thinking)) {
56
+ settings.defaultSubagentThinking = thinking;
57
+ } else {
58
+ warnings.push(`${label}: defaultSubagentThinking must be one of ${THINKING_LEVELS.join("|")}, got ${JSON.stringify(thinking)} — ignored`);
59
+ }
60
+ }
61
+
62
+ const known = new Set(["defaultSubagentThinking"]);
63
+ const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
64
+ if (unknownKeys.length > 0) {
65
+ warnings.push(`${label}: unknown setting${unknownKeys.length > 1 ? "s" : ""} ${unknownKeys.map((k) => `"${k}"`).join(", ")} — ignored (valid: ${[...known].join(", ")})`);
66
+ }
67
+
68
+ return { settings, warnings };
69
+ }
70
+
71
+ export interface FleetSettingsStoreOpts {
72
+ projectPath: string;
73
+ globalPath: string;
74
+ }
75
+
76
+ /** Global+project fleet settings (project wins per-field), mirroring TierStore's scheme. */
77
+ export class FleetSettingsStore {
78
+ constructor(private readonly opts: FleetSettingsStoreOpts) {}
79
+
80
+ private readOne(path: string, label: string): FleetSettingsResult {
81
+ let content: string;
82
+ try {
83
+ content = readFileSync(path, "utf8");
84
+ } catch (e) {
85
+ // ENOENT = the normal absent-file state, never a warning. Any OTHER read failure
86
+ // (EACCES, EISDIR, …) on a path that was expected to be readable must surface —
87
+ // silent swallows make misconfiguration invisible (the TierStore lesson).
88
+ const code = (e as NodeJS.ErrnoException).code;
89
+ if (code === "ENOENT") return { settings: {}, warnings: [] };
90
+ return { settings: {}, warnings: [`${label}: unreadable (${code ?? (e as Error).message}) — fleet settings from this file ignored`] };
91
+ }
92
+ return parseFleetSettings(content, label);
93
+ }
94
+
95
+ load(): FleetSettingsResult {
96
+ const global = this.readOne(this.opts.globalPath, this.opts.globalPath);
97
+ const project = this.readOne(this.opts.projectPath, this.opts.projectPath);
98
+ return {
99
+ settings: { ...global.settings, ...project.settings },
100
+ warnings: [...global.warnings, ...project.warnings],
101
+ };
102
+ }
103
+ }