@getpipher/armory-fleet 0.15.0 → 1.0.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 CHANGED
@@ -326,7 +326,79 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
326
326
  - **`userMemory` default flip:** the global cross-project user memory scope (`/__armory-fleet-user__`) is no longer hydrated by default. If you populated that dir + relied on it, add `userMemory: true` to the agent frontmatter (only meaningful with `memoryHydrate: true`). TS consumers constructing `AgentDef` literals must now include `userMemory: boolean` (required field; use `false` for the old default behavior).
327
327
  - **Lifecycle `cwd` field:** lifecycles accept an optional `cwd` frontmatter field to pin a target repo; absent → the entry-point cwd (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present, it overrides the entry-point cwd for all phases.
328
328
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
329
- - **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) tracked in #62.
329
+ - **bg/scheduled + worktree cwd-isolation (#62):** the `cwd` param now scopes background and scheduled runs too — in-place bg runs pass it as the lifecycle entry cwd, and `isolation: 'worktree'`/`'auto'` resolve isolation (and create the worktree) against the **dispatch cwd's** repo, not the session's. Cross-cwd bg worktrees land in `<child-cwd>/.pi/fleet/worktrees/`.
330
+
331
+ ## Event bus + RPC (v1.0)
332
+
333
+ armory-fleet publishes every run on pi's cross-extension event bus and answers an RPC verb
334
+ set — so your own extensions can spawn, steer, observe, and abort subagents programmatically.
335
+
336
+ ### Event stream (broadcast, `pi.events`)
337
+
338
+ Two tiers, every event enveloped as `{ runId, seq, ts, ...payload }` (`seq` = per-run
339
+ monotonic, one space per source store):
340
+
341
+ | Channel | Payload |
342
+ |---|---|
343
+ | `fleet:run:started` | `{ agent, model?, cwd?, sessionCwd?, mode: foreground\|background\|scheduled\|workflow, task }` |
344
+ | `fleet:phase:started` / `completed` / `failed` | `{ phase, … }` (lifecycle runs) |
345
+ | `fleet:run:ended` | `{ status: completed\|failed\|aborted, result?, error?, filesTouched?, toolCallCount?, durationMs? }` |
346
+ | `fleet:child:message` | `{ role, text }` (journal excerpts) |
347
+ | `fleet:child:tool` | `{ toolName, args, result, isError }` (one per completed tool call) |
348
+
349
+ ### RPC (`fleet:rpc` → `fleet:rpc:result`)
350
+
351
+ Emit `{ id, verb, params }`, get exactly one reply `{ id, ok, data }` or
352
+ `{ id, ok: false, error: { code, message } }`. Verbs: `spawn` (returns `{ runId }`
353
+ immediately; result arrives via `fleet:run:ended`), `status`, `observe` (replay dump —
354
+ subscribe to the broadcast channels + dedupe by `(channel, runId, seq)` for the live tail),
355
+ `steer`, `abort`.
356
+
357
+ Error codes: `E-CONTROL-DISABLED`, `E-RUN-NOT-FOUND`, `E-RUN-FINISHED`, `E-BAD-VERB`,
358
+ `E-BAD-PARAMS`, `E-STEER-UNSUPPORTED`, `E-INTERNAL`.
359
+
360
+ ### Client helper (~15 lines)
361
+
362
+ ```ts
363
+ type FleetReply = { id: string; ok: true; data: any } | { id: string; ok: false; error: { code: string; message: string } };
364
+ let n = 0;
365
+ export function fleetRpc(pi: { events: { emit(c: string, d: unknown): void; on(c: string, h: (d: unknown) => void): () => void } }) {
366
+ return <T = any>(verb: string, params?: Record<string, unknown>): Promise<T> => {
367
+ const id = `fleet-${Date.now().toString(36)}-${++n}`;
368
+ return new Promise<T>((resolve, reject) => {
369
+ const unsub = pi.events.on("fleet:rpc:result", (raw) => {
370
+ const r = raw as FleetReply;
371
+ if (r.id !== id) return;
372
+ unsub();
373
+ r.ok ? resolve(r.data as T) : reject(new Error(`${r.error.code}: ${r.error.message}`));
374
+ });
375
+ pi.events.emit("fleet:rpc", { id, verb, params });
376
+ });
377
+ };
378
+ }
379
+ // const rpc = fleetRpc(pi); const { runId } = await rpc("spawn", { agent: "scout", task: "look" });
380
+ ```
381
+
382
+ ### Control gate
383
+
384
+ `spawn`/`steer`/`abort` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
385
+ reject them with `E-CONTROL-DISABLED`; read-only `observe`/`status` stay available. Honest
386
+ threat model: in-process extensions already have full system access through pi itself — the
387
+ switch guards accidents, not adversaries.
388
+
389
+ ### Live conversation viewer
390
+
391
+ `/fleet` → Runs → open a **running** run: the timeline overlay streams the child's
392
+ conversation as it happens (tail-follows; scroll up to read, back to the bottom to re-pin).
393
+ Finished runs replay from the journal exactly as before.
394
+
395
+
396
+
397
+ The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
398
+
399
+ - **bg/scheduled cwd** — in-place bg runs pass the dispatch cwd as the lifecycle entry cwd (full context scoping: cascade, skills, memory); schedules persist the pinned cwd and honor it on every fire.
400
+ - **Per-dispatch worktrees** — `isolation: 'worktree'` / `'auto'` resolve against the **dispatch cwd's repo**, not the session's; cross-cwd worktrees land in `<child-cwd>/.pi/fleet/worktrees/` and are cleaned up via the same service.
401
+ - **bg lifecycle parity** — the bg adapter now honors the lifecycle cwd chain (`lifecycle.cwd` → dispatch `cwd`), which also fixes a pre-existing mismatch where bg isolated runs spawned phases in the session cwd but committed in the worktree.
330
402
 
331
403
  ## Provider-agnostic tiers (v0.15.0)
332
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.15.0",
3
+ "version": "1.0.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",
@@ -132,6 +132,10 @@ export interface SpawnOptions {
132
132
  visionPort?: VisionPort;
133
133
  signal?: AbortSignal;
134
134
  onEvent?: (e: ChildSessionEvent) => void;
135
+ /** SPEC-6-4: pre-minted runId (RPC spawn replies before the detached spawn resolves). Absent → mint. */
136
+ runId?: string;
137
+ /** SPEC-6-4: dispatch origin for RunMetaEvent.mode. Default "foreground". */
138
+ mode?: "foreground" | "background" | "scheduled" | "workflow";
135
139
  /** SPEC-4: when set, this spawn is a lifecycle phase child. It links to this lifecycle todo
136
140
  * (not creates a new one) and finishRun skips mark-done/revert — the lifecycle engine owns
137
141
  * the lifecycle todo's status + progress block. */
@@ -241,13 +245,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
241
245
  const readOnly = opts.readOnly ?? false;
242
246
  let runId: string;
243
247
  if (readOnly) {
244
- runId = genRunId();
248
+ runId = opts.runId ?? genRunId();
245
249
  } else {
246
250
  // #31 tail: foreground concurrency lock. cap=1 (default) is FAIL-FAST (backward-compat — a
247
251
  // 2nd dispatch is rejected with the held runId + the cap + the env hint); cap>1 (opt-in via
248
252
  // ARMORY_FLEET_FOREGROUND_CONCURRENCY) QUEUES — up to `cap` writes run in parallel, the rest wait.
249
253
  // The cap is session-level (a shared lock can't be re-sized per dispatch); see concurrency-lock.ts.
250
- runId = genRunId();
254
+ runId = opts.runId ?? genRunId();
251
255
  const acq = await opts.lock.acquire(runId);
252
256
  if (!acq.ok) {
253
257
  const hint = opts.lock.cap === 1
@@ -401,7 +405,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
401
405
  if (e.type === "session_init" && e.backendSessionId) {
402
406
  opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
403
407
  try {
404
- opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: childCwd, sessionCwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
408
+ opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: childCwd, sessionCwd: opts.parentCwd, mode: opts.mode ?? "foreground", pid: (session as { proc?: { pid?: number } }).proc?.pid });
405
409
  } catch { /* best-effort */ }
406
410
  } else if (e.type === "turn_start") {
407
411
  turnIdx++;
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  getAgentDir,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
- import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
11
+ import { createSubagentTool, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
12
12
  // SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
13
13
  import { discoverAgents } from "./registry/discovery.ts";
14
14
  import { RunRegistry } from "./engine/run-registry.ts";
@@ -40,6 +40,8 @@ import { ResultsInbox } from "./runtime/results-inbox.ts";
40
40
  import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
41
41
  import { scanResumeCandidates } from "./runtime/resume.ts";
42
42
  import { RunLog } from "./runtime/run-log.ts";
43
+ import { FleetEventBus } from "./rpc/event-bus.ts";
44
+ import { RpcServer } from "./rpc/rpc-server.ts";
43
45
  import { reconcileRuns } from "./runtime/reconcile.ts";
44
46
  import { Scheduler } from "./scheduling/scheduler.ts";
45
47
  import { createFleetResultsTool } from "./tools/fleet-results.ts";
@@ -263,6 +265,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
263
265
  // SPEC-5b-2: the live widget (above editor) controller.
264
266
  // Display-only, independent of the /fleet panel; constructed per-session in session_start.
265
267
  let fleetWidget: FleetWidgetController | null = null;
268
+ // SPEC-6-4: hoisted so session_shutdown can dispose the bus + unsubscribe fleet:rpc.
269
+ let fleetBus: FleetEventBus | null = null;
270
+ let unsubscribeRpc: (() => void) | null = null;
266
271
  // SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
267
272
  let wfController: WorkflowController | null = null;
268
273
  let wfStore: WorkflowRunStore | null = null;
@@ -289,14 +294,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
289
294
  ...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
290
295
  spawn: withModelFallbackRetry(async (o) => spawnSubagent({
291
296
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
297
+ mode: opts.fleetMode ?? "background",
292
298
  skillsOverride: o.skills, backendOverride: o.backend,
293
299
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
294
300
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
295
301
  parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
302
+ // #62: in-place bg runs honor the lifecycle cwd resolution (lifecycle.cwd ?? entryCwd);
303
+ // isolated runs pin the phase cwd to the worktree. parentCwd stays the SESSION cwd for
304
+ // in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
305
+ cwd: isolated ? opts.worktreePath : o.cwd,
296
306
  runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
297
307
  }), deps.defaultModelFallback),
298
308
  };
299
- const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
309
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", entryCwd: opts.entryCwd, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
300
310
  return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
301
311
  };
302
312
  // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
@@ -355,6 +365,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
355
365
  // SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
356
366
  // SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
357
367
  deps.runLog = new RunLog(join(dir, "conversations"));
368
+ // SPEC-6-4: ONE shared RunJournal per session — the FleetEventBus subscribes to THIS
369
+ // instance, so every journal append (phases AND bookends) reaches the bus exactly once.
370
+ const fleetJournal = new RunJournal(join(dir, "runs"));
358
371
  // v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
359
372
  // (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
360
373
  // #22 bg-watchdog: pass todoSync so a process-gone run's linked TODO is reverted to open
@@ -366,10 +379,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
366
379
  ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
367
380
  }
368
381
  });
382
+ const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
369
383
  deps.asyncRunner = {
370
- worktree: new WorktreeService({ rootDir: ctx.cwd }),
384
+ worktree: sessionWorktree,
385
+ // #62: cross-cwd bg dispatches get a per-dispatch worktree service rooted at the dispatch
386
+ // cwd, so isolation:'worktree' creates the worktree from the CHILD's repo (the session
387
+ // service would create it from the session's repo — the #62 gap). Same-cwd → shared.
388
+ worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
371
389
  diff: new DiffService(),
372
- journal: new RunJournal(join(dir, "runs")),
390
+ journal: fleetJournal,
373
391
  pool: new ConcurrencyPool(3),
374
392
  inbox: resultsInbox,
375
393
  runLifecycle: asyncRunLifecycle,
@@ -383,7 +401,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
383
401
  lockPath: join(dir, "schedules.lock"),
384
402
  onFire: (spec) => {
385
403
  if (!deps.asyncRunner) return;
386
- runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
404
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd, origin: "scheduled" });
387
405
  },
388
406
  });
389
407
  deps.scheduler.start();
@@ -391,6 +409,82 @@ export default async function (pi: ExtensionAPI): Promise<void> {
391
409
  if (cands.length > 0) {
392
410
  ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
393
411
  }
412
+ // SPEC-6-4: defensive re-entrancy — a re-entered session_start must dispose the old
413
+ // bus/listener BEFORE constructing new ones (dispose-after-construction kills the fresh
414
+ // bus at birth and orphans the previous session's subscriptions).
415
+ fleetBus?.dispose();
416
+ fleetBus = null;
417
+ unsubscribeRpc?.();
418
+ unsubscribeRpc = null;
419
+ // SPEC-6-4: FleetEventBus — store appends → public fleet:* events on pi.events.
420
+ fleetBus = new FleetEventBus({
421
+ runLog: deps.runLog,
422
+ journal: fleetJournal,
423
+ emit: (channel, payload) => { pi.events.emit(channel, payload); },
424
+ });
425
+ // SPEC-6-4: fleet:rpc request surface — replies on fleet:rpc:result (null replies dropped).
426
+ const rpcServer = new RpcServer({
427
+ runRegistry: deps.runRegistry,
428
+ runLog: deps.runLog,
429
+ journal: fleetJournal,
430
+ parentCwd: ctx.cwd,
431
+ hasAsyncRunner: true,
432
+ spawn: (params: Record<string, unknown>, runId: string) => {
433
+ // Detached fire-and-forget: NEVER throws — spawnSubagent journals its own fail path
434
+ // (run:ended + todo revert), so the caller's pre-minted runId always resolves to events.
435
+ void (async () => {
436
+ const requestedCwd = typeof params.cwd === "string" ? params.cwd : undefined;
437
+ const { cwd: resolvedCwd } = resolveDispatchCwd(requestedCwd, ctx.cwd);
438
+ if (params.background === true) {
439
+ // RPC background: routed through the async runner (pool slot + isolation + origin),
440
+ // identical to the tool's runBackground path (spec §3.2). Ghost-runId prevention:
441
+ // a synchronous pre-flight failure journals run:ended failed under the caller's id.
442
+ const handle = runBackground(String(params.task), {
443
+ deps: deps.asyncRunner!,
444
+ lifecycle: "default",
445
+ mode: "auto",
446
+ isolation: params.isolation as "worktree" | "none" | "auto" | undefined,
447
+ cwd: resolvedCwd ?? ctx.cwd,
448
+ origin: "background",
449
+ runId,
450
+ });
451
+ if (handle.status === "failed") {
452
+ deps.runLog?.append(runId, { type: "run:ended", runId, status: "failed", endedAt: Date.now(), tokenTotal: 0, error: handle.error });
453
+ }
454
+ return;
455
+ }
456
+ // Foreground-semantics detached spawn (no mode — "foreground" is the truth).
457
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
458
+ await spawnSubagent({
459
+ agent: params.agent as string,
460
+ task: params.task as string,
461
+ todoId: typeof params.todoId === "string" ? params.todoId : undefined,
462
+ track: params.track === true,
463
+ model: typeof params.model === "string" ? params.model : undefined,
464
+ readOnly: params.readOnly === true,
465
+ skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
466
+ registry: deps.registry,
467
+ todoSync: deps.todoSync,
468
+ runRegistry: deps.runRegistry,
469
+ lock: deps.lock,
470
+ backendRegistry: deps.backendRegistry,
471
+ parentModel: deps.parentModel,
472
+ parentCwd: ctx.cwd,
473
+ runLog: deps.runLog,
474
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
475
+ tierRegistry: deps.tierRegistry,
476
+ modelRegistry: deps.modelRegistry,
477
+ cwd: resolvedCwd ?? ctx.cwd,
478
+ runId,
479
+ });
480
+ })().catch(() => {});
481
+ },
482
+ });
483
+ unsubscribeRpc = pi.events.on("fleet:rpc", (data: unknown) => {
484
+ void rpcServer.handle(data).then((reply) => {
485
+ if (reply) pi.events.emit("fleet:rpc:result", reply);
486
+ });
487
+ });
394
488
  // SPEC-5b-2: live widget (above editor). Display-only, independent
395
489
  // of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
396
490
  const getModelContextWindow = (m: string): number | undefined => {
@@ -492,6 +586,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
492
586
  });
493
587
 
494
588
  pi.on("session_shutdown", () => {
589
+ // SPEC-6-4: dispose the event bus + unsubscribe fleet:rpc before the other resets.
590
+ fleetBus?.dispose();
591
+ fleetBus = null;
592
+ unsubscribeRpc?.();
593
+ unsubscribeRpc = null;
495
594
  if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
496
595
  // SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
497
596
  // Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
@@ -573,6 +672,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
573
672
  ...deps.lifecycleDeps,
574
673
  spawn: withModelFallbackRetry(async (o) => spawnSubagent({
575
674
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
675
+ mode: "workflow",
576
676
  skillsOverride: o.skills, backendOverride: o.backend,
577
677
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
578
678
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
@@ -16,6 +16,7 @@ import { runsRow, runTimelineRow } from "./runs-rows.ts";
16
16
  import { messageBody, toolBody, messageHeader, toolHeader } from "./conversation-rows.ts";
17
17
  import { buildRunsIndex } from "./runs-index.ts";
18
18
  import type { RunLog, RunMeta, RunLogEvent, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
19
+ import { LiveTimelineState } from "./live-timeline.ts";
19
20
  import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
20
21
  import type { BgRunsStore } from "./bg-runs-store.ts";
21
22
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
@@ -132,6 +133,43 @@ export class FleetPanel extends Container {
132
133
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
133
134
  private readonly unsubs: (() => void)[] = [];
134
135
  private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
136
+ // SPEC-6-4: live mode for the timeline overlay (running runs stream; finished runs replay).
137
+ private liveUnsub: (() => void) | null = null;
138
+ private liveState: LiveTimelineState | null = null;
139
+
140
+ private renderedTimelineCount(): number {
141
+ return (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool").length;
142
+ }
143
+
144
+ /** SPEC-6-4: release the live subscription (overlay close / different run / panel close). */
145
+ private releaseLiveTimeline(): void {
146
+ this.liveUnsub?.();
147
+ this.liveUnsub = null;
148
+ this.liveState = null;
149
+ }
150
+
151
+ /** SPEC-6-4: shared timeline-open (Runs view + gate evidence). Hydrates replay; LIVE when the
152
+ * run is still running — subscribes to appends and tail-follows via LiveTimelineState. */
153
+ private openRunTimeline(runId: string): void {
154
+ const runLog = this.deps.runLog;
155
+ if (!runLog) return;
156
+ const meta = buildRunsIndex(runLog.dir).find((r) => r.runId === runId) ?? null;
157
+ this.selectedRun = meta;
158
+ this.runTimeline = runLog.replay(runId);
159
+ this.releaseLiveTimeline();
160
+ if (meta?.status === "running") {
161
+ this.liveState = new LiveTimelineState();
162
+ this.liveState.index = Math.max(0, this.renderedTimelineCount() - 1);
163
+ this.liveUnsub = runLog.subscribe((rid, ev) => {
164
+ if (rid !== runId || (ev.type !== "message" && ev.type !== "tool")) return;
165
+ this.runTimeline = [...(this.runTimeline ?? []), ev];
166
+ const idx = this.liveState?.append(this.renderedTimelineCount());
167
+ this.selectedEventIndex = idx ?? null;
168
+ this.renderShell();
169
+ });
170
+ }
171
+ this.renderShell();
172
+ }
135
173
  // SPEC-6-3: Workflows view — inline Run prompt input state
136
174
  private wfRunMode = false;
137
175
  private wfPromptInput: Input | null = null;
@@ -205,6 +243,8 @@ export class FleetPanel extends Container {
205
243
  this.closed = true;
206
244
  for (const u of this.unsubs) u();
207
245
  this.unsubs.length = 0;
246
+ // SPEC-6-4: the live timeline subscription dies with the panel.
247
+ this.releaseLiveTimeline();
208
248
  this.onDone(intent ?? null);
209
249
  }
210
250
 
@@ -324,7 +364,7 @@ export class FleetPanel extends Container {
324
364
  this.selectedEventIndex = null;
325
365
  }
326
366
  // esc → back to Runs list (replaces the v0.6.0 panel-level escape catch).
327
- tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
367
+ tl.onCancel = () => { this.releaseLiveTimeline(); this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
328
368
  this.timelineList = tl;
329
369
  this.addChild(tl);
330
370
  }
@@ -512,8 +552,7 @@ export class FleetPanel extends Container {
512
552
  .flatMap((p) => p.gateResults ?? [])
513
553
  .find((gr) => gr.runId);
514
554
  if (agentGate?.runId && this.deps.runLog) {
515
- this.selectedRun = buildRunsIndex(this.deps.runLog.dir).find((r) => r.runId === agentGate.runId) ?? null;
516
- this.runTimeline = this.deps.runLog.replay(agentGate.runId);
555
+ this.openRunTimeline(agentGate.runId);
517
556
  this.selectedLifecycle = null;
518
557
  this.view = "runs";
519
558
  this.renderShell();
@@ -555,8 +594,17 @@ export class FleetPanel extends Container {
555
594
  return;
556
595
  }
557
596
  if (this.selectedRun) {
558
- // SPEC-5b-3: forward to the timeline SelectList (arrows scroll; enter via onSelect opens the
559
- // overlay; esc via onCancel returns to the Runs list). v0.6.0 swallowed all non-escape keys.
597
+ // SPEC-6-4: in live mode the panel owns up/down (tail-follow cursor) intercepted before
598
+ // the SelectList forward; everything else forwards as before. Finished runs: replay path.
599
+ if (this.liveState && (matchesKey(data, "up") || matchesKey(data, "down"))) {
600
+ const total = this.renderedTimelineCount();
601
+ const key = matchesKey(data, "up") ? "up" : "down";
602
+ if (this.liveState.onKey(key, total)) {
603
+ this.selectedEventIndex = this.liveState.index;
604
+ this.renderShell();
605
+ }
606
+ return;
607
+ }
560
608
  this.timelineList?.handleInput(data);
561
609
  this.invalidate();
562
610
  return;
@@ -645,9 +693,7 @@ export class FleetPanel extends Container {
645
693
  if (matchesKey(data, "enter") || matchesKey(data, "i")) {
646
694
  const sel = this.list.getSelectedItem();
647
695
  if (sel) {
648
- this.selectedRun = buildRunsIndex(this.deps.runLog.dir).find((r) => r.runId === sel.value) ?? null;
649
- this.runTimeline = this.deps.runLog.replay(sel.value);
650
- this.renderShell();
696
+ this.openRunTimeline(sel.value);
651
697
  }
652
698
  return;
653
699
  }
@@ -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,244 @@
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
+ }
44
+
45
+ const LIST_CAP = 25;
46
+ const TASK_SUMMARY_CAP = 80;
47
+
48
+ function summarize(r: RunRecord): RpcRunSummary {
49
+ const task = r.task.length > TASK_SUMMARY_CAP ? r.task.slice(0, TASK_SUMMARY_CAP - 1) + "…" : r.task;
50
+ return {
51
+ runId: r.runId, agent: r.agent, model: r.model, status: r.status, startedAt: r.startedAt,
52
+ ...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
53
+ task, ...(r.cwd ? { cwd: r.cwd } : {}),
54
+ ...(r.resultSummary !== undefined ? { resultSummary: r.resultSummary } : {}),
55
+ ...(r.tokenTotal !== undefined ? { tokenTotal: r.tokenTotal } : {}),
56
+ ...(r.sessionKey ? { sessionKey: r.sessionKey } : {}),
57
+ };
58
+ }
59
+
60
+ export class RpcServer {
61
+ constructor(
62
+ private readonly deps: RpcServerDeps,
63
+ private readonly controlEnabled: () => boolean = rpcControlEnabled,
64
+ ) {}
65
+
66
+ /** Returns the reply, or null for a malformed request with no usable id (caller drops).
67
+ * Never throws — a handler exception becomes E-INTERNAL. */
68
+ async handle(req: unknown): Promise<RpcReply | null> {
69
+ const id = requestId(req);
70
+ try {
71
+ return await this.dispatch(req, id);
72
+ } catch (e) {
73
+ if (!id) return null;
74
+ return { id, ok: false, error: { code: "E-INTERNAL", message: `unexpected rpc failure: ${(e as Error).message}` } };
75
+ }
76
+ }
77
+
78
+ private async dispatch(req: unknown, id: string | null): Promise<RpcReply | null> {
79
+ if (!req || typeof req !== "object" || !id) return null;
80
+ const { verb, params } = req as Record<string, unknown>;
81
+ if (typeof verb !== "string") return this.err(id, "E-BAD-VERB", "missing verb");
82
+ const gated = this.controlEnabled();
83
+ switch (verb) {
84
+ case "spawn": return gated ? this.spawnVerb(id, params) : this.controlDisabled(id);
85
+ case "steer": return gated ? this.steerVerb(id, params) : this.controlDisabled(id);
86
+ case "abort": return gated ? this.abortVerb(id, params) : this.controlDisabled(id);
87
+ case "observe": return this.observeVerb(id, params);
88
+ case "status": return this.statusVerb(id, params);
89
+ default: return this.err(id, "E-BAD-VERB", `unknown verb '${verb}' (known: spawn, steer, observe, abort, status)`);
90
+ }
91
+ }
92
+
93
+ private controlDisabled(id: string): RpcReply {
94
+ 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)");
95
+ }
96
+
97
+ private err(id: string, code: RpcErrorCode, message: string): RpcReply {
98
+ return { id, ok: false, error: { code, message } };
99
+ }
100
+
101
+ private obj(params: unknown): Record<string, unknown> | null {
102
+ return params && typeof params === "object" ? params as Record<string, unknown> : null;
103
+ }
104
+
105
+ private spawnVerb(id: string, params: unknown): RpcReply {
106
+ const p = this.obj(params);
107
+ if (!p) return this.err(id, "E-BAD-PARAMS", "spawn requires params: { agent, task, ... }");
108
+ if (typeof p.agent !== "string" || !p.agent) return this.err(id, "E-BAD-PARAMS", "params.agent must be a non-empty string");
109
+ if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
110
+ if (p.lifecycle !== undefined) return this.err(id, "E-BAD-PARAMS", "params.lifecycle is not supported over RPC spawn yet (single-delegate + background only — spec §7)");
111
+ if (p.schedule !== undefined) return this.err(id, "E-BAD-PARAMS", "params.schedule is not supported over RPC spawn yet");
112
+ if (p.modelFallback !== undefined) return this.err(id, "E-BAD-PARAMS", "params.modelFallback is not supported over RPC spawn yet");
113
+ 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");
114
+ if (p.cwd !== undefined) {
115
+ const { error } = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
116
+ if (error) return this.err(id, "E-BAD-PARAMS", error);
117
+ }
118
+ if (p.background !== undefined && typeof p.background !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.background must be a boolean");
119
+ if (p.background && !this.deps.hasAsyncRunner) return this.err(id, "E-BAD-PARAMS", "background runs not configured in this session (asyncRunner missing)");
120
+ if (p.isolation !== undefined && p.isolation !== "worktree" && p.isolation !== "none" && p.isolation !== "auto") {
121
+ return this.err(id, "E-BAD-PARAMS", "params.isolation must be 'worktree' | 'none' | 'auto'");
122
+ }
123
+ if (p.maxTurns !== undefined && (typeof p.maxTurns !== "number" || !Number.isInteger(p.maxTurns) || p.maxTurns < 1)) {
124
+ return this.err(id, "E-BAD-PARAMS", "params.maxTurns must be a positive integer");
125
+ }
126
+ if (p.readOnly !== undefined && typeof p.readOnly !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.readOnly must be a boolean");
127
+ if (p.track !== undefined && typeof p.track !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.track must be a boolean");
128
+ if (p.todoId !== undefined && typeof p.todoId !== "string") return this.err(id, "E-BAD-PARAMS", "params.todoId must be a string");
129
+ 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");
130
+ if (p.skills !== undefined && (!Array.isArray(p.skills) || !p.skills.every((s) => typeof s === "string"))) {
131
+ return this.err(id, "E-BAD-PARAMS", "params.skills must be an array of strings");
132
+ }
133
+ const runId = genRunId();
134
+ this.deps.spawn(p, runId);
135
+ return { id, ok: true, data: { runId } };
136
+ }
137
+
138
+ private statusVerb(id: string, params: unknown): RpcReply {
139
+ const p = this.obj(params) ?? {};
140
+ if (p.runId !== undefined) {
141
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
142
+ const rec = this.deps.runRegistry.get(p.runId);
143
+ 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)`);
144
+ return { id, ok: true, data: { runs: [summarize(rec)] } };
145
+ }
146
+ const runs = this.deps.runRegistry.list().slice(0, LIST_CAP).map(summarize);
147
+ return { id, ok: true, data: { runs } };
148
+ }
149
+
150
+ private observeVerb(id: string, params: unknown): RpcReply {
151
+ const p = this.obj(params);
152
+ if (!p) return this.err(id, "E-BAD-PARAMS", "observe requires params: { runId, tier? }");
153
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
154
+ const tier = p.tier ?? "both";
155
+ if (tier !== "lifecycle" && tier !== "child" && tier !== "both") {
156
+ return this.err(id, "E-BAD-PARAMS", "params.tier must be 'lifecycle' | 'child' | 'both'");
157
+ }
158
+ const logEvents = this.deps.runLog.replay(p.runId);
159
+ const journalEvents = this.deps.journal.replay(p.runId);
160
+ if (logEvents.length === 0 && journalEvents.length === 0) {
161
+ return this.err(id, "E-RUN-NOT-FOUND", `no journaled run '${p.runId}'`);
162
+ }
163
+ const events: Array<{ channel: string; payload: Record<string, unknown> }> = [];
164
+ // Seq = position in the FULL store event list (index + 1) — identical to the live bus's
165
+ // per-store dense counting, so the (channel, runId, seq) dedupe contract holds across
166
+ // the live→replay handoff. Tier filters decide WHICH entries emit, not how they count.
167
+ // Journal exception: the live bus increments phaseSeq ONLY on the three phase types, so
168
+ // replay filters to phases BEFORE counting — real lifecycle runs bookend the journal with
169
+ // run:started/completed, and position-in-full-list would overshoot by the bookends.
170
+ const phases = journalEvents.filter((e) => e.type === "phase:started" || e.type === "phase:completed" || e.type === "phase:failed");
171
+ if (tier === "lifecycle" || tier === "both") {
172
+ logEvents.forEach((e, i) => {
173
+ if (e.type === "run:meta") {
174
+ 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 } });
175
+ } else if (e.type === "run:ended") {
176
+ events.push({
177
+ channel: "fleet:run:ended",
178
+ payload: { seq: i + 1, status: e.status, ts: e.endedAt,
179
+ ...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
180
+ ...(e.error !== undefined ? { error: e.error } : {}),
181
+ ...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
182
+ ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}) },
183
+ });
184
+ }
185
+ });
186
+ phases.forEach((e, i) => {
187
+ if (e.type === "phase:started") events.push({ channel: "fleet:phase:started", payload: { seq: i + 1, phase: e.phase, ts: e.ts } });
188
+ 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 } });
189
+ 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 } });
190
+ });
191
+ }
192
+ if (tier === "child" || tier === "both") {
193
+ logEvents.forEach((e, i) => {
194
+ if (e.type === "message") events.push({ channel: "fleet:child:message", payload: { seq: i + 1, role: e.role, text: e.text } });
195
+ 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 } });
196
+ });
197
+ }
198
+ return { id, ok: true, data: { runId: p.runId, tier, events } };
199
+ }
200
+
201
+ private async steerVerb(id: string, params: unknown): Promise<RpcReply> {
202
+ const p = this.obj(params);
203
+ if (!p) return this.err(id, "E-BAD-PARAMS", "steer requires params: { runId, message }");
204
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
205
+ if (typeof p.message !== "string" || !p.message) return this.err(id, "E-BAD-PARAMS", "params.message must be a non-empty string");
206
+ const rec = this.deps.runRegistry.get(p.runId);
207
+ if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
208
+ const session = rec.session;
209
+ if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
210
+ if (!session.supportsSteer) return this.err(id, "E-STEER-UNSUPPORTED", `run '${p.runId}' backend has no steer support (claude children)`);
211
+ try {
212
+ await session.steer(p.message);
213
+ } catch (e) {
214
+ const msg = (e as Error).message ?? "steer failed";
215
+ if (msg.includes("not supported")) return this.err(id, "E-STEER-UNSUPPORTED", msg);
216
+ return this.err(id, "E-INTERNAL", `steer failed: ${msg}`);
217
+ }
218
+ return { id, ok: true, data: { steered: true } };
219
+ }
220
+
221
+ private async abortVerb(id: string, params: unknown): Promise<RpcReply> {
222
+ const p = this.obj(params);
223
+ if (!p) return this.err(id, "E-BAD-PARAMS", "abort requires params: { runId }");
224
+ if (typeof p.runId !== "string" || !p.runId) return this.err(id, "E-BAD-PARAMS", "params.runId must be a non-empty string");
225
+ const rec = this.deps.runRegistry.get(p.runId);
226
+ if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry`);
227
+ const session = rec.session;
228
+ if (!session) return this.err(id, "E-RUN-FINISHED", `run '${p.runId}' has no live session (status: ${rec.status})`);
229
+ try {
230
+ await session.abort();
231
+ } catch (e) {
232
+ const msg = (e as Error).message ?? "abort failed";
233
+ if (msg.includes("already")) return this.err(id, "E-RUN-FINISHED", msg);
234
+ return this.err(id, "E-INTERNAL", `abort failed: ${msg}`);
235
+ }
236
+ return { id, ok: true, data: { aborted: true } };
237
+ }
238
+ }
239
+
240
+ function requestId(req: unknown): string | null {
241
+ if (!req || typeof req !== "object") return null;
242
+ const id = (req as Record<string, unknown>).id;
243
+ return typeof id === "string" && id !== "" ? id : null;
244
+ }
@@ -26,6 +26,11 @@ 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";
31
+ /** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
32
+ * Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
33
+ entryCwd?: string;
29
34
  }
30
35
 
31
36
  export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
@@ -43,14 +48,28 @@ export interface AsyncRunnerDeps {
43
48
  onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
44
49
  /** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */
45
50
  runRegistry?: import("../engine/run-registry.ts").RunRegistry;
51
+ /** #62: per-dispatch worktree service for cross-cwd bg runs (rootDir = the dispatch cwd).
52
+ * Consulted whenever a bg dispatch carries a `cwd`; absent → deps.worktree (session-scoped,
53
+ * pre-#62 behavior). index.ts wires it so a cross-cwd worktree is created from the CHILD's
54
+ * repo, not the session's. */
55
+ worktreeFor?: (cwd: string) => WorktreeService;
46
56
  }
47
57
 
48
58
  export interface RunBackgroundOpts {
49
59
  deps: AsyncRunnerDeps;
50
60
  lifecycle: string;
51
61
  mode: "auto" | "checkpointed";
62
+ /** SPEC-6-4: dispatch origin for fleet:run:started `mode`. "scheduled" when fired by the scheduler. */
63
+ origin?: "background" | "scheduled";
64
+ /** SPEC-6-4: pre-minted runId (RPC spawn replies with the id BEFORE the detached bg run starts).
65
+ * Absent → the runner mints via deps.genRunId() exactly as before. */
66
+ runId?: string;
52
67
  /** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
53
68
  isolation?: Isolation;
69
+ /** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
70
+ * isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
71
+ * and in-place runs pass it as the lifecycle entryCwd. */
72
+ cwd?: string;
54
73
  }
55
74
 
56
75
  /** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
@@ -82,9 +101,16 @@ function sh(cmd: string, cwd: string): void {
82
101
  execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
83
102
  }
84
103
 
104
+ /** #62: the worktree service for a dispatch — per-dispatch (rootDir = the dispatch cwd) when
105
+ * deps.worktreeFor is wired, else the session-scoped deps.worktree (back-compat). */
106
+ function worktreeFor(deps: AsyncRunnerDeps, cwd?: string): WorktreeService {
107
+ if (!cwd) return deps.worktree;
108
+ return deps.worktreeFor?.(cwd) ?? deps.worktree;
109
+ }
110
+
85
111
  /** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
86
112
  * Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
87
- function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
113
+ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }, worktree: WorktreeService = opts.deps.worktree): void {
88
114
  const { deps } = opts;
89
115
  void deps.pool.withSlot(async () => {
90
116
  try {
@@ -94,7 +120,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
94
120
  deps.journal.append(runId, ev0);
95
121
  emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
96
122
 
97
- const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
123
+ 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" });
98
124
 
99
125
  if (res.status === "completed") {
100
126
  if (isolated) {
@@ -116,17 +142,17 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
116
142
  };
117
143
  deps.inbox.push(result);
118
144
  deps.notify(`fleet run ${runId} completed`, "info");
119
- if (isolated) deps.worktree.removeWorktree(runId);
145
+ if (isolated) worktree.removeWorktree(runId);
120
146
  } else {
121
147
  deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
122
- if (isolated) deps.worktree.remove(runId);
148
+ if (isolated) worktree.remove(runId);
123
149
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
124
150
  deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
125
151
  }
126
152
  } catch (e) {
127
153
  const msg = (e as Error).message;
128
154
  deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
129
- if (isolated) deps.worktree.remove(runId);
155
+ if (isolated) worktree.remove(runId);
130
156
  deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
131
157
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
132
158
  }
@@ -135,34 +161,34 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
135
161
 
136
162
  /** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
137
163
  function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
138
- const { deps } = opts;
139
- if (!deps.worktree.isGitRepo()) {
164
+ const worktree = worktreeFor(opts.deps, opts.cwd);
165
+ if (!worktree.isGitRepo()) {
140
166
  return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
141
167
  }
142
- const runId = deps.genRunId();
168
+ const runId = opts.runId ?? opts.deps.genRunId();
143
169
  const baseRef = "HEAD";
144
170
  let wt: { path: string; branch: string };
145
171
  try {
146
- wt = deps.worktree.create(runId, baseRef);
172
+ wt = worktree.create(runId, baseRef);
147
173
  } catch (e) {
148
174
  return { status: "failed", error: (e as Error).message };
149
175
  }
150
- runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
176
+ runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch }, worktree);
151
177
  return { runId, status: "background" };
152
178
  }
153
179
 
154
- /** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
180
+ /** The auto router: isolated when the DISPATCH cwd is a git repo, in-place + one per-session notify when not. */
155
181
  function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
156
- const { deps } = opts;
157
- if (deps.worktree.isGitRepo()) {
182
+ const worktree = worktreeFor(opts.deps, opts.cwd);
183
+ if (worktree.isGitRepo()) {
158
184
  return runBackgroundIsolated(task, opts);
159
185
  }
160
186
  if (!inPlaceFallbackWarned) {
161
187
  inPlaceFallbackWarned = true;
162
- deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
188
+ opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
163
189
  }
164
- const runId = deps.genRunId();
165
- runBackgroundInPlace(runId, task, opts);
190
+ const runId = opts.runId ?? opts.deps.genRunId();
191
+ runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
166
192
  return { runId, status: "background" };
167
193
  }
168
194
 
@@ -171,8 +197,8 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
171
197
  const isolation = opts.isolation ?? "auto";
172
198
  if (isolation === "worktree") return runBackgroundIsolated(task, opts);
173
199
  if (isolation === "none") {
174
- const runId = opts.deps.genRunId();
175
- runBackgroundInPlace(runId, task, opts);
200
+ const runId = opts.runId ?? opts.deps.genRunId();
201
+ runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
176
202
  return { runId, status: "background" };
177
203
  }
178
204
  return runBackgroundAuto(task, opts);
@@ -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
 
@@ -13,6 +13,8 @@ export interface ScheduleSpec {
13
13
  auto?: boolean;
14
14
  /** v0.11.1: edit isolation for the background run on fire. Default "auto". */
15
15
  isolation?: "worktree" | "none" | "auto";
16
+ /** #62: the dispatch target cwd (undefined = session cwd). Threaded to runBackground on fire. */
17
+ cwd?: string;
16
18
  }
17
19
 
18
20
  export interface Schedule extends ScheduleSpec {
@@ -74,6 +76,7 @@ export class Scheduler {
74
76
  lifecycle: spec.lifecycle ?? "default",
75
77
  auto: spec.auto ?? true,
76
78
  isolation: spec.isolation,
79
+ ...(spec.cwd ? { cwd: spec.cwd } : {}),
77
80
  paused: false,
78
81
  };
79
82
  this.schedules.set(id, { spec: stored, expr, timer: null });
@@ -90,6 +93,7 @@ export class Scheduler {
90
93
  lifecycle: e.spec.lifecycle,
91
94
  auto: e.spec.auto,
92
95
  isolation: e.spec.isolation,
96
+ ...(e.spec.cwd ? { cwd: e.spec.cwd } : {}),
93
97
  paused: e.spec.paused,
94
98
  nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
95
99
  }));
@@ -131,13 +131,13 @@ export function createSubagentTool(deps: SubagentToolDeps) {
131
131
  }
132
132
  if (params.schedule) {
133
133
  if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
134
- const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
134
+ const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation, cwd: resolvedCwd });
135
135
  const entry = deps.scheduler.list().find((s) => s.id === id);
136
136
  return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
137
137
  }
138
138
  if (params.background) {
139
139
  if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
140
- const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
140
+ const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation, cwd: resolvedCwd });
141
141
  if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
142
142
  return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
143
143
  }