@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.
package/README.md CHANGED
@@ -254,6 +254,20 @@ Models are an ordered fallback chain (primary first; a spawn retries the next ca
254
254
 
255
255
  Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
256
256
 
257
+ ### Fleet settings (v1.1.0)
258
+
259
+ `~/.pi/agent/fleet/settings.json` (global) and `<project>/.pi/fleet/settings.json` (project — wins per-field) hold fleet-wide defaults that aren't env-shaped. Currently one field:
260
+
261
+ ```json
262
+ {
263
+ "defaultSubagentThinking": "high"
264
+ }
265
+ ```
266
+
267
+ `defaultSubagentThinking` (#78) sets the thinking level for **every subagent whose frontmatter does not pin `thinkingLevel`** — so a controller can think at `max` while the fleet runs at `high`, without replacing builtin agents (agent-frontmatter overrides still win; per-dispatch overrides are not exposed yet). Valid values: `off | minimal | low | medium | high | xhigh | max`. On flat subscriptions (z.ai/GLM coding plans, Ollama) this is the quota lever — thinking tokens on every cheap subagent call are pure waste.
268
+
269
+ Invalid values and unknown keys produce a startup warning naming the file, the field, and the bad value — never a silent no-op. Absent files are normal.
270
+
257
271
  ### Operational runtime
258
272
 
259
273
  `src/runtime/` — the async/scheduling spine:
@@ -328,7 +342,82 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
328
342
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
329
343
  - **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
344
 
331
- ## Cross-cwd everywhere (v0.16.0)
345
+ ## Event bus + RPC (v1.0)
346
+
347
+ armory-fleet publishes every run on pi's cross-extension event bus and answers an RPC verb
348
+ set — so your own extensions can spawn, steer, observe, and abort subagents programmatically.
349
+
350
+ ### Event stream (broadcast, `pi.events`)
351
+
352
+ Two tiers, every event enveloped as `{ runId, seq, ts, ...payload }` (`seq` = per-run
353
+ monotonic, one space per source store):
354
+
355
+ | Channel | Payload |
356
+ |---|---|
357
+ | `fleet:run:started` | `{ agent, model?, cwd?, sessionCwd?, mode: foreground\|background\|scheduled\|workflow, task }` |
358
+ | `fleet:phase:started` / `completed` / `failed` | `{ phase, … }` (lifecycle runs) |
359
+ | `fleet:run:ended` | `{ status: completed\|failed\|aborted, result?, error?, filesTouched?, toolCallCount?, durationMs? }` |
360
+ | `fleet:child:message` | `{ role, text }` (journal excerpts) |
361
+ | `fleet:child:tool` | `{ toolName, args, result, isError }` (one per completed tool call) |
362
+
363
+ ### RPC (`fleet:rpc` → `fleet:rpc:result`)
364
+
365
+ Emit `{ id, verb, params }`, get exactly one reply `{ id, ok, data }` or
366
+ `{ id, ok: false, error: { code, message } }`. Verbs: `spawn` (returns `{ runId }`
367
+ immediately; result arrives via `fleet:run:ended`), `schedule` (#83 — returns
368
+ `{ scheduleId, nextFire }`; schedules run lifecycles, not single delegates), `status`,
369
+ `observe` (replay dump — subscribe to the broadcast channels + dedupe by
370
+ `(channel, runId, seq)` for the live tail), `steer`, `abort`.
371
+
372
+ `spawn` params: `agent`, `task`, `background?`, `lifecycle?` (named lifecycle — with
373
+ `background: true` routes through the bg runner; without it runs as a detached
374
+ foreground-semantics lifecycle), `modelFallback?` (per-request retry-once on a retryable
375
+ provider failure — the retry mints a fresh runId and relinks the primary's todo), `cwd?`,
376
+ `isolation?`, `maxTurns?`, `model?`, `skills?`, `readOnly?`, `track?`, `todoId?`.
377
+
378
+ `schedule` params: `task`, `expression` (cron or interval), `lifecycle?`, `auto?`,
379
+ `isolation?`, `cwd?` — no `agent`: the scheduler runs lifecycles only. Registration emits
380
+ no run events; on fire, the normal `fleet:*` stream flows with `mode: "scheduled"`.
381
+
382
+ Error codes: `E-CONTROL-DISABLED`, `E-RUN-NOT-FOUND`, `E-RUN-FINISHED`, `E-BAD-VERB`,
383
+ `E-BAD-PARAMS`, `E-STEER-UNSUPPORTED`, `E-INTERNAL`.
384
+
385
+ ### Client helper (~15 lines)
386
+
387
+ ```ts
388
+ type FleetReply = { id: string; ok: true; data: any } | { id: string; ok: false; error: { code: string; message: string } };
389
+ let n = 0;
390
+ export function fleetRpc(pi: { events: { emit(c: string, d: unknown): void; on(c: string, h: (d: unknown) => void): () => void } }) {
391
+ return <T = any>(verb: string, params?: Record<string, unknown>): Promise<T> => {
392
+ const id = `fleet-${Date.now().toString(36)}-${++n}`;
393
+ return new Promise<T>((resolve, reject) => {
394
+ const unsub = pi.events.on("fleet:rpc:result", (raw) => {
395
+ const r = raw as FleetReply;
396
+ if (r.id !== id) return;
397
+ unsub();
398
+ r.ok ? resolve(r.data as T) : reject(new Error(`${r.error.code}: ${r.error.message}`));
399
+ });
400
+ pi.events.emit("fleet:rpc", { id, verb, params });
401
+ });
402
+ };
403
+ }
404
+ // const rpc = fleetRpc(pi); const { runId } = await rpc("spawn", { agent: "scout", task: "look" });
405
+ ```
406
+
407
+ ### Control gate
408
+
409
+ `spawn`/`steer`/`abort`/`schedule` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
410
+ reject them with `E-CONTROL-DISABLED`; read-only `observe`/`status` stay available. Honest
411
+ threat model: in-process extensions already have full system access through pi itself — the
412
+ switch guards accidents, not adversaries.
413
+
414
+ ### Live conversation viewer
415
+
416
+ `/fleet` → Runs → open a **running** run: the timeline overlay streams the child's
417
+ conversation as it happens (tail-follows; scroll up to read, back to the bottom to re-pin).
418
+ Finished runs replay from the journal exactly as before.
419
+
420
+
332
421
 
333
422
  The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
334
423
 
@@ -336,6 +425,14 @@ The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and schedu
336
425
  - **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.
337
426
  - **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.
338
427
 
428
+ ## RPC parity + fleet settings (v1.1.0)
429
+
430
+ Post-v1.0 batch (issues #78/#83):
431
+
432
+ - **`fleet.defaultSubagentThinking` (#78)** — a fleet-dir settings home (`~/.pi/agent/fleet/settings.json` global, `<project>/.pi/fleet/settings.json` project override) whose first field sets the thinking level for every subagent that doesn't pin its own — the quota lever for flat subscriptions. See [Fleet settings](#fleet-settings-v110).
433
+ - **RPC spawn parity (#83)** — `spawn` gains `lifecycle` (bg routing, or a detached foreground-semantics lifecycle run) and `modelFallback` (per-request retry-once; the retry mints a fresh runId and relinks the primary's todo).
434
+ - **New `schedule` verb (#83)** — `{ task, expression, lifecycle?, auto?, isolation?, cwd? }` → `{ scheduleId, nextFire }`. Schedules run lifecycles only, so `spawn`'s uniform `{ runId }` reply stays unbranched; the frozen surface needed zero renames and zero new error codes.
435
+
339
436
  ## Provider-agnostic tiers (v0.15.0)
340
437
 
341
438
  Small-backlog batch (issues #57/#63/#64/#65):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.16.0",
3
+ "version": "1.1.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",
@@ -31,3 +31,17 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
31
31
  return first;
32
32
  };
33
33
  }
34
+
35
+ /** #83 D3: the RPC foreground-semantics detached spawn's retry — mirrors the tool's direct-path
36
+ * contract (retry ONCE, retryable failures only, DISTINCT fallback model) but the retry runs
37
+ * WITHOUT the pre-minted runId: the primary already journaled run:started/ended under it, so
38
+ * the retry must mint a fresh id (a reused id would double-emit run:started). `todoId` links
39
+ * the retry to the primary's armory-todo task (same relink contract as the tool). */
40
+ export async function retryForegroundOnce(
41
+ primary: SpawnResult,
42
+ fallback: string | undefined,
43
+ spawn: (o: { model: string; todoId?: string }) => Promise<SpawnResult>,
44
+ ): Promise<SpawnResult> {
45
+ if (!fallback || primary.status !== "failed" || !primary.retryable || fallback === primary.model) return primary;
46
+ return spawn({ model: fallback, todoId: primary.todoId ?? undefined });
47
+ }
@@ -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. */
@@ -154,6 +158,10 @@ export interface SpawnOptions {
154
158
  modelRegistry?: ModelRegistryLike;
155
159
  /** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
156
160
  tierOverride?: string;
161
+ /** #78: fleet-wide default thinking level (`fleet.defaultSubagentThinking` settings field).
162
+ * Applied only when the agent frontmatter does NOT pin `thinkingLevel` — precedence:
163
+ * agent.thinkingLevel > opts.defaultThinkingLevel > backend/session default. */
164
+ defaultThinkingLevel?: ThinkingLevel;
157
165
  /** #31: when true, the caller asserts this dispatch will NOT mutate the working directory
158
166
  * (review/audit/research). Read-only dispatches bypass the foreground single-slot lock so
159
167
  * multiple readOnly dispatches — and/or a readOnly dispatch alongside a write dispatch — can
@@ -241,13 +249,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
241
249
  const readOnly = opts.readOnly ?? false;
242
250
  let runId: string;
243
251
  if (readOnly) {
244
- runId = genRunId();
252
+ runId = opts.runId ?? genRunId();
245
253
  } else {
246
254
  // #31 tail: foreground concurrency lock. cap=1 (default) is FAIL-FAST (backward-compat — a
247
255
  // 2nd dispatch is rejected with the held runId + the cap + the env hint); cap>1 (opt-in via
248
256
  // ARMORY_FLEET_FOREGROUND_CONCURRENCY) QUEUES — up to `cap` writes run in parallel, the rest wait.
249
257
  // The cap is session-level (a shared lock can't be re-sized per dispatch); see concurrency-lock.ts.
250
- runId = genRunId();
258
+ runId = opts.runId ?? genRunId();
251
259
  const acq = await opts.lock.acquire(runId);
252
260
  if (!acq.ok) {
253
261
  const hint = opts.lock.cap === 1
@@ -331,7 +339,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
331
339
  const result = await backend.factory.create({
332
340
  cwd: childCwd,
333
341
  model: cand,
334
- thinkingLevel: childAgent.thinkingLevel,
342
+ // #78: agent frontmatter wins; the fleet-wide default fills the gap.
343
+ thinkingLevel: childAgent.thinkingLevel ?? opts.defaultThinkingLevel,
335
344
  tools,
336
345
  rolePrompt: childAgent.rolePrompt,
337
346
  skills: childAgent.skills ?? [],
@@ -401,7 +410,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
401
410
  if (e.type === "session_init" && e.backendSessionId) {
402
411
  opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
403
412
  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 });
413
+ 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
414
  } catch { /* best-effort */ }
406
415
  } else if (e.type === "turn_start") {
407
416
  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, mergeLifecycleSkills, 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";
@@ -50,6 +52,7 @@ import { FleetWidgetController } from "./panel/fleet-widget.ts";
50
52
  import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
51
53
  import { BUILTIN_TIERS } from "./tiers/builtin.ts";
52
54
  import { TierStore } from "./tiers/tier-store.ts";
55
+ import { FleetSettingsStore } from "./settings/fleet-settings.ts";
53
56
  import { splitModel } from "./tiers/resolve.ts";
54
57
  import { WorkflowJournal } from "./workflows/journal.ts";
55
58
  import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts";
@@ -263,6 +266,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
263
266
  // SPEC-5b-2: the live widget (above editor) controller.
264
267
  // Display-only, independent of the /fleet panel; constructed per-session in session_start.
265
268
  let fleetWidget: FleetWidgetController | null = null;
269
+ // SPEC-6-4: hoisted so session_shutdown can dispose the bus + unsubscribe fleet:rpc.
270
+ let fleetBus: FleetEventBus | null = null;
271
+ let unsubscribeRpc: (() => void) | null = null;
266
272
  // SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
267
273
  let wfController: WorkflowController | null = null;
268
274
  let wfStore: WorkflowRunStore | null = null;
@@ -289,6 +295,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
289
295
  ...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
290
296
  spawn: withModelFallbackRetry(async (o) => spawnSubagent({
291
297
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
298
+ mode: opts.fleetMode ?? "background",
292
299
  skillsOverride: o.skills, backendOverride: o.backend,
293
300
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
294
301
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
@@ -298,7 +305,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
298
305
  // in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
299
306
  cwd: isolated ? opts.worktreePath : o.cwd,
300
307
  runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
301
- }), deps.defaultModelFallback),
308
+ defaultThinkingLevel: deps.defaultSubagentThinking,
309
+ }), opts.modelFallback ?? deps.defaultModelFallback),
302
310
  };
303
311
  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" } });
304
312
  return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
@@ -359,6 +367,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
359
367
  // SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
360
368
  // SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
361
369
  deps.runLog = new RunLog(join(dir, "conversations"));
370
+ // SPEC-6-4: ONE shared RunJournal per session — the FleetEventBus subscribes to THIS
371
+ // instance, so every journal append (phases AND bookends) reaches the bus exactly once.
372
+ const fleetJournal = new RunJournal(join(dir, "runs"));
362
373
  // v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
363
374
  // (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
364
375
  // #22 bg-watchdog: pass todoSync so a process-gone run's linked TODO is reverted to open
@@ -378,7 +389,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
378
389
  // service would create it from the session's repo — the #62 gap). Same-cwd → shared.
379
390
  worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
380
391
  diff: new DiffService(),
381
- journal: new RunJournal(join(dir, "runs")),
392
+ journal: fleetJournal,
382
393
  pool: new ConcurrencyPool(3),
383
394
  inbox: resultsInbox,
384
395
  runLifecycle: asyncRunLifecycle,
@@ -392,7 +403,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
392
403
  lockPath: join(dir, "schedules.lock"),
393
404
  onFire: (spec) => {
394
405
  if (!deps.asyncRunner) return;
395
- runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd });
406
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd, origin: "scheduled" });
396
407
  },
397
408
  });
398
409
  deps.scheduler.start();
@@ -400,6 +411,159 @@ export default async function (pi: ExtensionAPI): Promise<void> {
400
411
  if (cands.length > 0) {
401
412
  ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
402
413
  }
414
+ // SPEC-6-4: defensive re-entrancy — a re-entered session_start must dispose the old
415
+ // bus/listener BEFORE constructing new ones (dispose-after-construction kills the fresh
416
+ // bus at birth and orphans the previous session's subscriptions).
417
+ fleetBus?.dispose();
418
+ fleetBus = null;
419
+ unsubscribeRpc?.();
420
+ unsubscribeRpc = null;
421
+ // SPEC-6-4: FleetEventBus — store appends → public fleet:* events on pi.events.
422
+ fleetBus = new FleetEventBus({
423
+ runLog: deps.runLog,
424
+ journal: fleetJournal,
425
+ emit: (channel, payload) => { pi.events.emit(channel, payload); },
426
+ });
427
+ // SPEC-6-4: fleet:rpc request surface — replies on fleet:rpc:result (null replies dropped).
428
+ const rpcServer = new RpcServer({
429
+ runRegistry: deps.runRegistry,
430
+ runLog: deps.runLog,
431
+ journal: fleetJournal,
432
+ parentCwd: ctx.cwd,
433
+ hasAsyncRunner: true,
434
+ // #83 D4: schedule registration — scheduler.register + nextFire lookup. Throws (invalid
435
+ // expression) propagate to the verb, which maps them to E-BAD-PARAMS.
436
+ schedule: (spec) => {
437
+ if (!deps.scheduler) throw new Error("scheduling not configured in this session (scheduler missing)");
438
+ const id = deps.scheduler.register({
439
+ task: String(spec.task),
440
+ expression: String(spec.expression),
441
+ ...(typeof spec.lifecycle === "string" ? { lifecycle: spec.lifecycle } : {}),
442
+ ...(typeof spec.auto === "boolean" ? { auto: spec.auto } : {}),
443
+ ...(spec.isolation === "worktree" || spec.isolation === "none" || spec.isolation === "auto" ? { isolation: spec.isolation } : {}),
444
+ ...(typeof spec.cwd === "string" ? { cwd: spec.cwd } : {}),
445
+ });
446
+ const entry = deps.scheduler.list().find((s) => s.id === id);
447
+ return { scheduleId: id, nextFire: entry?.nextFire?.toISOString() ?? null };
448
+ },
449
+ spawn: (params: Record<string, unknown>, runId: string) => {
450
+ // Detached fire-and-forget: NEVER throws — spawnSubagent journals its own fail path
451
+ // (run:ended + todo revert), so the caller's pre-minted runId always resolves to events.
452
+ void (async () => {
453
+ const requestedCwd = typeof params.cwd === "string" ? params.cwd : undefined;
454
+ const { cwd: resolvedCwd } = resolveDispatchCwd(requestedCwd, ctx.cwd);
455
+ // #83: lifecycle + per-request modelFallback params (validated by the verb; re-narrowed here).
456
+ const rpcLifecycle = typeof params.lifecycle === "string" && params.lifecycle ? params.lifecycle : "default";
457
+ const rpcFallback = typeof params.modelFallback === "string" && params.modelFallback ? params.modelFallback : undefined;
458
+ if (params.background === true) {
459
+ // RPC background: routed through the async runner (pool slot + isolation + origin),
460
+ // identical to the tool's runBackground path (spec §3.2). Ghost-runId prevention:
461
+ // a synchronous pre-flight failure journals run:ended failed under the caller's id.
462
+ const handle = runBackground(String(params.task), {
463
+ deps: deps.asyncRunner!,
464
+ lifecycle: rpcLifecycle,
465
+ mode: "auto",
466
+ isolation: params.isolation as "worktree" | "none" | "auto" | undefined,
467
+ cwd: resolvedCwd ?? ctx.cwd,
468
+ origin: "background",
469
+ runId,
470
+ ...(rpcFallback ? { modelFallback: rpcFallback } : {}),
471
+ });
472
+ if (handle.status === "failed") {
473
+ deps.runLog?.append(runId, { type: "run:ended", runId, status: "failed", endedAt: Date.now(), tokenTotal: 0, error: handle.error });
474
+ }
475
+ return;
476
+ }
477
+ if (params.lifecycle !== undefined) {
478
+ // #83 D2: lifecycle WITHOUT background = detached foreground-semantics lifecycle run
479
+ // (tool parity: mode "auto", failed-phase checkpoint aborts, session lock on the phase
480
+ // spawn, pre-minted runId via the genRunId override — same pattern as asyncRunLifecycle).
481
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
482
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
483
+ const { withModelFallbackRetry } = await import("./engine/retry-fallback.ts");
484
+ const lifecycleFullDeps = {
485
+ ...deps.lifecycleDeps,
486
+ genRunId: () => runId,
487
+ spawn: withModelFallbackRetry(async (o) => spawnSubagent({
488
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
489
+ skillsOverride: mergeLifecycleSkills(o.skills, Array.isArray(params.skills) ? params.skills as string[] : undefined),
490
+ backendOverride: o.backend,
491
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
492
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: ctx.cwd,
493
+ runLog: deps.runLog, signal: undefined,
494
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
495
+ defaultThinkingLevel: deps.defaultSubagentThinking,
496
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
497
+ readOnly: params.readOnly === true,
498
+ cwd: o.cwd ?? resolvedCwd,
499
+ }), rpcFallback ?? deps.defaultModelFallback),
500
+ };
501
+ const res = await runLifecycle(String(params.task), rpcLifecycle, {
502
+ deps: lifecycleFullDeps, mode: "auto", entryCwd: resolvedCwd,
503
+ onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" },
504
+ });
505
+ deps.lifecycleRuns.set(res.runId, res); // panel Lifecycle-view visibility (workflow-lambda precedent)
506
+ return;
507
+ }
508
+ // Foreground-semantics detached spawn (no mode — "foreground" is the truth).
509
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
510
+ const { retryForegroundOnce } = await import("./engine/retry-fallback.ts");
511
+ const primary = await spawnSubagent({
512
+ agent: params.agent as string,
513
+ task: params.task as string,
514
+ todoId: typeof params.todoId === "string" ? params.todoId : undefined,
515
+ track: params.track === true,
516
+ model: typeof params.model === "string" ? params.model : undefined,
517
+ readOnly: params.readOnly === true,
518
+ skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
519
+ registry: deps.registry,
520
+ todoSync: deps.todoSync,
521
+ runRegistry: deps.runRegistry,
522
+ lock: deps.lock,
523
+ backendRegistry: deps.backendRegistry,
524
+ parentModel: deps.parentModel,
525
+ parentCwd: ctx.cwd,
526
+ runLog: deps.runLog,
527
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
528
+ tierRegistry: deps.tierRegistry,
529
+ modelRegistry: deps.modelRegistry,
530
+ cwd: resolvedCwd ?? ctx.cwd,
531
+ runId,
532
+ defaultThinkingLevel: deps.defaultSubagentThinking,
533
+ });
534
+ // #83 D3: per-request fallback retry — the retry mints a FRESH runId (the primary's
535
+ // pre-minted id stays retired; a reuse would double-emit run:started) and relinks the
536
+ // primary's todo, exactly like the tool's direct path.
537
+ await retryForegroundOnce(primary, rpcFallback, (o) => spawnSubagent({
538
+ agent: params.agent as string,
539
+ task: params.task as string,
540
+ todoId: o.todoId,
541
+ track: params.track === true,
542
+ model: o.model,
543
+ readOnly: params.readOnly === true,
544
+ skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
545
+ registry: deps.registry,
546
+ todoSync: deps.todoSync,
547
+ runRegistry: deps.runRegistry,
548
+ lock: deps.lock,
549
+ backendRegistry: deps.backendRegistry,
550
+ parentModel: deps.parentModel,
551
+ parentCwd: ctx.cwd,
552
+ runLog: deps.runLog,
553
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
554
+ tierRegistry: deps.tierRegistry,
555
+ modelRegistry: deps.modelRegistry,
556
+ cwd: resolvedCwd ?? ctx.cwd,
557
+ defaultThinkingLevel: deps.defaultSubagentThinking,
558
+ }));
559
+ })().catch(() => {});
560
+ },
561
+ });
562
+ unsubscribeRpc = pi.events.on("fleet:rpc", (data: unknown) => {
563
+ void rpcServer.handle(data).then((reply) => {
564
+ if (reply) pi.events.emit("fleet:rpc:result", reply);
565
+ });
566
+ });
403
567
  // SPEC-5b-2: live widget (above editor). Display-only, independent
404
568
  // of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
405
569
  const getModelContextWindow = (m: string): number | undefined => {
@@ -436,6 +600,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
436
600
  deps.reloadTiers = reloadTiers;
437
601
  reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
438
602
 
603
+ // #78: fleet-dir settings.json (global + project, project wins) — currently carries
604
+ // `defaultSubagentThinking`. Warnings are surfaced, never swallowed silently.
605
+ const fleetSettingsStore = new FleetSettingsStore({
606
+ projectPath: join(dir, "settings.json"),
607
+ globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "settings.json"),
608
+ });
609
+ const fleetSettings = fleetSettingsStore.load();
610
+ for (const w of fleetSettings.warnings) ctx.ui.notify(w, "warning");
611
+ deps.defaultSubagentThinking = fleetSettings.settings.defaultSubagentThinking;
612
+
439
613
  // SPEC-6-3: workflow journal + registry + runner + fleet tool wiring.
440
614
  const workflowJournal = new WorkflowJournal(join(dir, "workflows"));
441
615
  wfRegistry = new WorkflowRegistry(discoverWorkflows({
@@ -458,6 +632,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
458
632
  runLog: deps.runLog,
459
633
  tierRegistry: deps.tierRegistry,
460
634
  modelRegistry: deps.modelRegistry,
635
+ defaultThinkingLevel: deps.defaultSubagentThinking,
461
636
  lifecycleDeps: deps.lifecycleDeps,
462
637
  spawnSubagentFn: async (opts) => { const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); return spawnSubagent(opts); },
463
638
  runLifecycleFn: async (task, name, lcOpts) => { const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); return runLifecycle(task, name, lcOpts); },
@@ -501,6 +676,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
501
676
  });
502
677
 
503
678
  pi.on("session_shutdown", () => {
679
+ // SPEC-6-4: dispose the event bus + unsubscribe fleet:rpc before the other resets.
680
+ fleetBus?.dispose();
681
+ fleetBus = null;
682
+ unsubscribeRpc?.();
683
+ unsubscribeRpc = null;
504
684
  if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
505
685
  // SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
506
686
  // Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
@@ -582,10 +762,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
582
762
  ...deps.lifecycleDeps,
583
763
  spawn: withModelFallbackRetry(async (o) => spawnSubagent({
584
764
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
765
+ mode: "workflow",
585
766
  skillsOverride: o.skills, backendOverride: o.backend,
586
767
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
587
768
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
588
769
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
770
+ defaultThinkingLevel: deps.defaultSubagentThinking,
589
771
  }), deps.defaultModelFallback),
590
772
  };
591
773
  const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
@@ -9,13 +9,14 @@ import {
9
9
  matchesKey,
10
10
  type SelectItem,
11
11
  } from "@earendil-works/pi-tui";
12
- import type { AgentDef } from "../registry/frontmatter.ts";
12
+ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
13
13
  import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
14
  import { buildFleetItems } from "./fleet-items.ts";
15
15
  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";
@@ -45,6 +46,9 @@ export interface FleetPanelDeps {
45
46
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
46
47
  parentModel: { provider: string; id: string };
47
48
  parentCwd: string;
49
+ /** #78: fleet-wide default thinking level (settings.json `defaultSubagentThinking`).
50
+ * Threads to every panel-driven spawn (Run/Resume/Fork + lifecycle-view phases). */
51
+ defaultSubagentThinking?: ThinkingLevel;
48
52
  /** SPEC-4: lifecycle registry + active/recent run records + deps to drive checkpoints. */
49
53
  lifecycleRegistry: Map<string, LifecycleDef>;
50
54
  lifecycleRuns: Map<string, LifecycleRunRecord>;
@@ -132,6 +136,43 @@ export class FleetPanel extends Container {
132
136
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
133
137
  private readonly unsubs: (() => void)[] = [];
134
138
  private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
139
+ // SPEC-6-4: live mode for the timeline overlay (running runs stream; finished runs replay).
140
+ private liveUnsub: (() => void) | null = null;
141
+ private liveState: LiveTimelineState | null = null;
142
+
143
+ private renderedTimelineCount(): number {
144
+ return (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool").length;
145
+ }
146
+
147
+ /** SPEC-6-4: release the live subscription (overlay close / different run / panel close). */
148
+ private releaseLiveTimeline(): void {
149
+ this.liveUnsub?.();
150
+ this.liveUnsub = null;
151
+ this.liveState = null;
152
+ }
153
+
154
+ /** SPEC-6-4: shared timeline-open (Runs view + gate evidence). Hydrates replay; LIVE when the
155
+ * run is still running — subscribes to appends and tail-follows via LiveTimelineState. */
156
+ private openRunTimeline(runId: string): void {
157
+ const runLog = this.deps.runLog;
158
+ if (!runLog) return;
159
+ const meta = buildRunsIndex(runLog.dir).find((r) => r.runId === runId) ?? null;
160
+ this.selectedRun = meta;
161
+ this.runTimeline = runLog.replay(runId);
162
+ this.releaseLiveTimeline();
163
+ if (meta?.status === "running") {
164
+ this.liveState = new LiveTimelineState();
165
+ this.liveState.index = Math.max(0, this.renderedTimelineCount() - 1);
166
+ this.liveUnsub = runLog.subscribe((rid, ev) => {
167
+ if (rid !== runId || (ev.type !== "message" && ev.type !== "tool")) return;
168
+ this.runTimeline = [...(this.runTimeline ?? []), ev];
169
+ const idx = this.liveState?.append(this.renderedTimelineCount());
170
+ this.selectedEventIndex = idx ?? null;
171
+ this.renderShell();
172
+ });
173
+ }
174
+ this.renderShell();
175
+ }
135
176
  // SPEC-6-3: Workflows view — inline Run prompt input state
136
177
  private wfRunMode = false;
137
178
  private wfPromptInput: Input | null = null;
@@ -205,6 +246,8 @@ export class FleetPanel extends Container {
205
246
  this.closed = true;
206
247
  for (const u of this.unsubs) u();
207
248
  this.unsubs.length = 0;
249
+ // SPEC-6-4: the live timeline subscription dies with the panel.
250
+ this.releaseLiveTimeline();
208
251
  this.onDone(intent ?? null);
209
252
  }
210
253
 
@@ -324,7 +367,7 @@ export class FleetPanel extends Container {
324
367
  this.selectedEventIndex = null;
325
368
  }
326
369
  // 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(); };
370
+ tl.onCancel = () => { this.releaseLiveTimeline(); this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
328
371
  this.timelineList = tl;
329
372
  this.addChild(tl);
330
373
  }
@@ -443,6 +486,7 @@ export class FleetPanel extends Container {
443
486
  backendRegistry: this.deps.backendRegistry,
444
487
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
445
488
  runLog: this.deps.runLog,
489
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
446
490
  // live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
447
491
  onEvent: (e) => {
448
492
  if (e.type === "turn_end") {
@@ -512,8 +556,7 @@ export class FleetPanel extends Container {
512
556
  .flatMap((p) => p.gateResults ?? [])
513
557
  .find((gr) => gr.runId);
514
558
  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);
559
+ this.openRunTimeline(agentGate.runId);
517
560
  this.selectedLifecycle = null;
518
561
  this.view = "runs";
519
562
  this.renderShell();
@@ -555,8 +598,17 @@ export class FleetPanel extends Container {
555
598
  return;
556
599
  }
557
600
  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.
601
+ // SPEC-6-4: in live mode the panel owns up/down (tail-follow cursor) intercepted before
602
+ // the SelectList forward; everything else forwards as before. Finished runs: replay path.
603
+ if (this.liveState && (matchesKey(data, "up") || matchesKey(data, "down"))) {
604
+ const total = this.renderedTimelineCount();
605
+ const key = matchesKey(data, "up") ? "up" : "down";
606
+ if (this.liveState.onKey(key, total)) {
607
+ this.selectedEventIndex = this.liveState.index;
608
+ this.renderShell();
609
+ }
610
+ return;
611
+ }
560
612
  this.timelineList?.handleInput(data);
561
613
  this.invalidate();
562
614
  return;
@@ -645,9 +697,7 @@ export class FleetPanel extends Container {
645
697
  if (matchesKey(data, "enter") || matchesKey(data, "i")) {
646
698
  const sel = this.list.getSelectedItem();
647
699
  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();
700
+ this.openRunTimeline(sel.value);
651
701
  }
652
702
  return;
653
703
  }
@@ -1071,6 +1121,7 @@ export class FleetPanel extends Container {
1071
1121
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
1072
1122
  lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
1073
1123
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1124
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1074
1125
  onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
1075
1126
  });
1076
1127
  this.list = this.buildList();
@@ -1110,6 +1161,7 @@ export class FleetPanel extends Container {
1110
1161
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
1111
1162
  lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
1112
1163
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1164
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1113
1165
  onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
1114
1166
  });
1115
1167
  this.list = this.buildList();
@@ -1182,6 +1234,7 @@ export class FleetPanel extends Container {
1182
1234
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
1183
1235
  backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1184
1236
  runLog: this.deps.runLog,
1237
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1185
1238
  cwd: o.cwd,
1186
1239
  });
1187
1240
  },