pi-agent-fleet 0.4.0 → 0.5.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/src/canvas.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
- import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { readdir, readFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import type { AddressInfo } from "node:net";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import type { ActiveFleet } from "./controller.js";
10
- import { readState } from "./state.js";
11
- import type { FleetSpec, FleetState } from "./types.js";
10
+ import { listFleetRoots, readDiskFleet } from "./fleet-recovery.js";
11
+
12
+ export { listFleetRoots, readDiskFleet };
13
+ export type { FleetRootInfo } from "./fleet-recovery.js";
12
14
 
13
15
  const execFileP = promisify(execFile);
14
16
 
@@ -479,31 +481,129 @@ export interface SessionEntryView {
479
481
  text: string;
480
482
  }
481
483
 
482
- export function parseSessionTail(jsonl: string, maxEntries: number): SessionEntryView[] {
483
- const out: SessionEntryView[] = [];
484
- for (const line of jsonl.split("\n")) {
485
- if (!line.includes('"type":"message"')) continue;
486
- try {
487
- const e = JSON.parse(line) as { message?: { role?: unknown; content?: unknown } };
488
- const msg = e.message;
489
- if (!msg || typeof msg.role !== "string" || !Array.isArray(msg.content)) continue;
490
- const parts: string[] = [];
491
- for (const p of msg.content as Array<{ type?: string; text?: string; name?: string }>) {
492
- if (p?.type === "text" && typeof p.text === "string") parts.push(p.text);
493
- else if ((p?.type === "toolCall" || p?.type === "tool_call") && typeof p.name === "string") parts.push(`[tool: ${p.name}]`);
494
- else if (p?.type === "toolResult" || p?.type === "tool_result") parts.push("[tool result]");
495
- }
496
- const text = parts.join("\n").trim();
497
- if (text.length > 0) {
498
- out.push({ role: msg.role as string, text: text.length > 4000 ? `${text.slice(0, 4000)}…` : text });
484
+ export interface ActionView {
485
+ type: "tool_call" | "tool_result" | "model_change" | "thinking_level_change" | "complete";
486
+ name?: string;
487
+ toolName?: string;
488
+ arguments?: Record<string, unknown>;
489
+ provider?: string;
490
+ modelId?: string;
491
+ thinkingLevel?: string;
492
+ stopReason?: string;
493
+ isError?: boolean;
494
+ timestamp?: string;
495
+ }
496
+
497
+ export type TimelineEvent =
498
+ | { type: "message"; role: string; text: string; timestamp?: string }
499
+ | { type: "tool_call"; name: string; arguments?: Record<string, unknown>; timestamp?: string }
500
+ | { type: "tool_result"; toolName?: string; isError?: boolean; text?: string; timestamp?: string }
501
+ | { type: "model_change"; provider: string; modelId: string; timestamp?: string }
502
+ | { type: "thinking_level_change"; thinkingLevel: string; timestamp?: string }
503
+ | { type: "complete"; stopReason: string; timestamp?: string };
504
+
505
+ export interface SessionTailView {
506
+ entries: SessionEntryView[];
507
+ actions: ActionView[];
508
+ events: TimelineEvent[];
509
+ }
510
+
511
+ export function parseSessionTail(jsonl: string, maxEntries: number): SessionTailView {
512
+ const events: TimelineEvent[] = [];
513
+ const entries: SessionEntryView[] = [];
514
+ const actions: ActionView[] = [];
515
+ for (const raw of jsonl.split("\n")) {
516
+ if (!raw.trim()) continue;
517
+ let e: { type?: string; timestamp?: string; message?: { role?: unknown; content?: unknown; stopReason?: string; toolName?: string; isError?: boolean }; provider?: string; modelId?: string; thinkingLevel?: string } | undefined;
518
+ try { e = JSON.parse(raw); } catch { continue; }
519
+ if (!e) continue;
520
+ const ts = typeof e.timestamp === "string" ? e.timestamp : undefined;
521
+ if (e.type === "model_change" && typeof e.provider === "string" && typeof e.modelId === "string") {
522
+ const a: ActionView = { type: "model_change", provider: e.provider, modelId: e.modelId, timestamp: ts };
523
+ actions.push(a);
524
+ events.push({ type: "model_change", provider: e.provider, modelId: e.modelId, timestamp: ts });
525
+ continue;
526
+ }
527
+ if (e.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
528
+ const a: ActionView = { type: "thinking_level_change", thinkingLevel: e.thinkingLevel, timestamp: ts };
529
+ actions.push(a);
530
+ events.push({ type: "thinking_level_change", thinkingLevel: e.thinkingLevel, timestamp: ts });
531
+ continue;
532
+ }
533
+ if (e.type !== "message") continue;
534
+ const msg = e.message;
535
+ if (!msg || typeof msg.role !== "string" || !Array.isArray(msg.content)) continue;
536
+ // tool result messages carry the result at the message level
537
+ if (msg.role === "toolResult" || msg.role === "tool_result") {
538
+ const a: ActionView = { type: "tool_result", toolName: typeof msg.toolName === "string" ? msg.toolName : undefined, isError: msg.isError, timestamp: ts };
539
+ actions.push(a);
540
+ events.push({ type: "tool_result", toolName: a.toolName, isError: a.isError, timestamp: ts });
541
+ }
542
+ const parts: string[] = [];
543
+ for (const p of msg.content as Array<{ type?: string; text?: string; name?: string; toolName?: string; isError?: boolean; arguments?: Record<string, unknown> }>) {
544
+ if (!p) continue;
545
+ if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
546
+ else if ((p.type === "toolCall" || p.type === "tool_call") && typeof p.name === "string") {
547
+ parts.push(`[tool: ${p.name}]`);
548
+ const a: ActionView = { type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts };
549
+ actions.push(a);
550
+ events.push({ type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts });
551
+ } else if (p.type === "toolResult" || p.type === "tool_result") {
552
+ parts.push("[tool result]");
553
+ const a: ActionView = { type: "tool_result", toolName: typeof p.toolName === "string" ? p.toolName : p.name, isError: p.isError, timestamp: ts };
554
+ actions.push(a);
555
+ events.push({ type: "tool_result", toolName: a.toolName, isError: a.isError, timestamp: ts });
499
556
  }
500
- } catch {
501
- // skip unparseable line
557
+ }
558
+ if (msg.stopReason && typeof msg.stopReason === "string" && msg.stopReason !== "toolUse" && msg.stopReason !== "tool_use") {
559
+ const a: ActionView = { type: "complete", stopReason: msg.stopReason, timestamp: ts };
560
+ actions.push(a);
561
+ events.push({ type: "complete", stopReason: msg.stopReason, timestamp: ts });
562
+ }
563
+ const text = parts.join("\n").trim();
564
+ if (text.length > 0) {
565
+ const entry: SessionEntryView = { role: msg.role as string, text: text.length > 4000 ? `${text.slice(0, 4000)}…` : text };
566
+ entries.push(entry);
567
+ events.push({ type: "message", role: entry.role, text: entry.text, timestamp: ts });
502
568
  }
503
569
  }
504
- return out.slice(-maxEntries);
570
+ return { entries: entries.slice(-maxEntries), actions: actions.slice(-maxEntries), events: events.slice(-maxEntries) };
505
571
  }
506
572
 
573
+ const DEMO_TASK = `You are L1 (lay-of-the-land) researcher in a 2-layer research fleet.\n\nMission context: founder built QuickCall — a daemon watching engineers' AI coding-agent sessions (Claude Code, Cursor, Codex), extracting team conventions, capturing accept/reject signals and human corrections on agent output.\n\nYour job:\n1. Read the mission brief and upstream inputs.\n2. Search for relevant precedents, code patterns, and competitive landscape.\n3. Write a concise markdown report to the required output path.\n4. Save ALL output files to the worker output directory using absolute paths.\n\nSave the report to output/l1-methods.md. The file must use markdown headings and keep each section focused. Do not modify source code.`;
574
+
575
+ export function buildDemoSession(_id: string, task?: string): SessionTailView & { task: string } {
576
+ const taskText = task || DEMO_TASK;
577
+ const baseTs = "2026-08-01T09:57:";
578
+ const events: TimelineEvent[] = [
579
+ { type: "model_change", provider: "openai-codex", modelId: "gpt-5.4-mini", timestamp: baseTs + "04.987Z" },
580
+ { type: "thinking_level_change", thinkingLevel: "high", timestamp: baseTs + "04.987Z" },
581
+ { type: "message", role: "assistant", text: "I'll review the scheduler changes and write the verdict.", timestamp: baseTs + "05.000Z" },
582
+ { type: "tool_call", name: "read", arguments: { path: "docs/superpowers/specs/reviewer-contract.md" }, timestamp: baseTs + "06.000Z" },
583
+ { type: "tool_call", name: "read", arguments: { path: "src/scheduler.ts" }, timestamp: baseTs + "06.500Z" },
584
+ { type: "message", role: "assistant", text: "The scheduler uses a priority queue. I'll run the test suite to verify behavior.", timestamp: baseTs + "08.000Z" },
585
+ { type: "tool_call", name: "bash", arguments: { command: "npm run typecheck" }, timestamp: baseTs + "09.000Z" },
586
+ { type: "tool_result", toolName: "bash", isError: false, text: "✓ typecheck passed", timestamp: baseTs + "12.000Z" },
587
+ { type: "message", role: "assistant", text: "Typecheck passes. I'll search for fleet reviewer patterns and inspect tests.", timestamp: baseTs + "12.500Z" },
588
+ { type: "tool_call", name: "web_search", arguments: { queries: ["fleet reviewer DAG pattern"] }, timestamp: baseTs + "13.000Z" },
589
+ { type: "tool_call", name: "read", arguments: { path: "test/scheduler.test.ts" }, timestamp: baseTs + "15.000Z" },
590
+ { type: "tool_result", toolName: "read", isError: false, text: "# 123", timestamp: baseTs + "15.500Z" },
591
+ { type: "message", role: "assistant", text: "Upstream outputs look good. Tests pass and search results confirm the pattern. Writing the review verdict now.", timestamp: baseTs + "17.000Z" },
592
+ { type: "tool_call", name: "write", arguments: { path: "output/review.md" }, timestamp: baseTs + "18.000Z" },
593
+ { type: "tool_result", toolName: "write", isError: false, text: "Successfully wrote 45 bytes to output/review.md", timestamp: baseTs + "18.500Z" },
594
+ { type: "message", role: "assistant", text: "Done.", timestamp: baseTs + "19.000Z" },
595
+ { type: "complete", stopReason: "complete", timestamp: baseTs + "20.000Z" },
596
+ ];
597
+ const entries: SessionEntryView[] = events.filter((e) => e.type === "message").map((e) => ({ role: (e as TimelineEvent & { type: "message" }).role, text: (e as TimelineEvent & { type: "message" }).text }));
598
+ const actions: ActionView[] = events.filter((e) => e.type !== "message").map((e) => {
599
+ if (e.type === "tool_call") return { type: e.type, name: e.name, arguments: e.arguments, timestamp: e.timestamp };
600
+ if (e.type === "tool_result") return { type: e.type, toolName: e.toolName, isError: e.isError, timestamp: e.timestamp };
601
+ if (e.type === "model_change") return { type: e.type, provider: e.provider, modelId: e.modelId, timestamp: e.timestamp };
602
+ if (e.type === "thinking_level_change") return { type: e.type, thinkingLevel: e.thinkingLevel, timestamp: e.timestamp };
603
+ return { type: e.type, stopReason: e.stopReason, timestamp: e.timestamp };
604
+ }) as ActionView[];
605
+ return { entries, actions, events, task: taskText };
606
+ }
507
607
  async function latestSessionFile(workerDir: string): Promise<string | undefined> {
508
608
  try {
509
609
  const files = (await readdir(workerDir)).filter((f) => f.endsWith(".jsonl")).sort();
@@ -626,14 +726,49 @@ main { display:flex; flex:1 1 auto; min-height:0; }
626
726
  .flags { margin-top:6px; font-size:11px; color:var(--warn); }
627
727
  .flags span { cursor:help; }
628
728
  .note { margin-top:6px; font-size:13px; color:var(--warn); }
629
- #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:auto; padding:12px; background:var(--bg); }
729
+ #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column; background:var(--bg); }
630
730
  #side:focus, #side:focus-visible { outline:none; }
631
- .side-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
632
- .side-head .meta { color:var(--muted); }
731
+ .side-head { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; flex-shrink:0; gap:6px; padding:12px 12px 8px; border-bottom:1px solid var(--line); background:var(--bg); }
732
+ .side-head .side-meta { display:flex; gap:6px; width:100%; }
733
+ .side-head .side-meta-chip { font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:4px; padding:2px 6px; white-space:nowrap; }
734
+ .side-head .meta { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:4px; }
735
+ .side-head .side-hash { color:var(--muted); font-weight:400; }
736
+ .side-head .side-id { font-weight:700; color:var(--hdr); font-size:15px; letter-spacing:-0.01em; }
737
+ .side-body { flex:1; overflow:auto; padding:8px 12px 12px; }
633
738
  .taskbox-side { border:1px solid var(--line); border-radius:6px; padding:8px; margin-bottom:10px; white-space:pre-wrap; word-break:break-word; font-size:13px; }
634
739
  .msg { margin-bottom:10px; padding:8px; border-radius:6px; background:var(--panel); white-space:pre-wrap; word-break:break-word; }
635
740
  .msg .role { font-weight:700; margin-bottom:4px; }
636
741
  .role-user { color:var(--accent); } .role-assistant { color:var(--ok); } .role-tool { color:var(--warn); }
742
+ .collapsible { position:sticky; top:8px; z-index:1; border:1px solid var(--line); border-radius:6px; margin-bottom:10px; background:var(--bg); }
743
+ .collapsible-head { display:flex; align-items:center; gap:6px; width:100%; padding:8px; background:transparent; border:none; font:inherit; font-size:12px; color:var(--muted); cursor:pointer; text-align:left; }
744
+ .collapsible-head:hover { background:var(--panel); }
745
+ .collapsible-body { padding:8px; border-top:1px solid var(--line); white-space:pre-wrap; word-break:break-word; font-size:13px; max-height:260px; overflow:auto; }
746
+ .collapsible.collapsed .collapsible-body { display:none; }
747
+ .collapsible .chevron { display:inline-block; transition:transform .15s; }
748
+ .collapsible.collapsed .chevron { transform:rotate(-90deg); }
749
+ .timeline { margin-bottom:12px; }
750
+ .timeline-action { background:var(--panel); border-radius:5px; margin-bottom:5px; }
751
+ .timeline-action.open { background:var(--panel-2); }
752
+ .timeline-action.action-error { border-left:3px solid var(--bad); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
753
+ .timeline-row { display:flex; align-items:center; gap:8px; padding:5px 8px; font-size:12px; }
754
+ .timeline-row .action-icon { flex-shrink:0; width:34px; text-align:center; color:var(--accent); font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:0.02em; }
755
+ .timeline-row .action-name { flex-shrink:0; font-weight:600; min-width:48px; font-size:12px; white-space:nowrap; }
756
+ .timeline-row .action-detail { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
757
+ .timeline-row .action-error { color:var(--bad); }
758
+ .timeline-row .ts { flex-shrink:0; color:var(--wire); font-size:11px; font-variant-numeric:tabular-nums; }
759
+ .timeline-msg { padding:8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
760
+ .timeline-msg.user { border-left:1px solid var(--accent); background:color-mix(in srgb, var(--accent) 5%, var(--panel)); }
761
+ .timeline-msg.assistant { border-left:1px solid var(--ok); background:color-mix(in srgb, var(--ok) 5%, var(--panel)); }
762
+ .timeline-msg .role { font-weight:700; margin-bottom:4px; }
763
+ .timeline-msg .timeline-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
764
+ .timeline-msg .timeline-meta .ts { color:var(--wire); font-size:11px; }
765
+ .timeline-msg .timeline-text { white-space:pre-wrap; word-break:break-word; }
766
+ .timeline-loading { display:flex; align-items:center; justify-content:center; gap:8px; padding:16px 8px; color:var(--muted); font-size:13px; }
767
+ .timeline-empty { padding:16px 8px; color:var(--muted); font-size:13px; text-align:center; }
768
+ .activity-toggle { flex-shrink:0; width:18px; height:18px; padding:0; background:transparent; border:1px solid var(--line); border-radius:4px; color:var(--muted); cursor:pointer; font-size:11px; line-height:1; }
769
+ .activity-toggle:hover { background:var(--bg); color:var(--fg); }
770
+ .activity-body { padding:8px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); font-family:var(--mono); white-space:pre-wrap; word-break:break-word; }
771
+ .activity-body pre { margin:0; background:var(--bg); padding:8px; border-radius:4px; overflow:auto; font-size:11px; }
637
772
  .react-flow__minimap-node.st-completed { fill:var(--ok); }
638
773
  .react-flow__minimap-node.st-running { fill:var(--accent); }
639
774
  .react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
@@ -713,63 +848,6 @@ export async function renderCanvasPage(): Promise<string> {
713
848
  </html>`;
714
849
  }
715
850
 
716
- export interface FleetRootInfo {
717
- name: string;
718
- root: string;
719
- status: string;
720
- created_at: string;
721
- }
722
-
723
- export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
724
- const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
725
- const state = await readState(fleetRoot);
726
- return {
727
- spec,
728
- fleetRoot,
729
- state,
730
- killSwitch: { killed: false },
731
- pauseSwitch: { paused: false },
732
- running: false,
733
- sessions: new Map(),
734
- killedNodes: new Set(),
735
- };
736
- }
737
-
738
- export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
739
- const base = join(cwd, ".fleet");
740
- let entries: string[];
741
- try {
742
- entries = await readdir(base);
743
- } catch {
744
- return [];
745
- }
746
- const out: FleetRootInfo[] = [];
747
- for (const name of entries) {
748
- const root = join(base, name);
749
- try {
750
- const s = await stat(join(root, "fleet.json"));
751
- if (!s.isFile()) continue;
752
- const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
753
- out.push({
754
- name,
755
- root,
756
- status: typeof state.status === "string" ? state.status : "unknown",
757
- created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
758
- });
759
- } catch {
760
- // not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
761
- try {
762
- await stat(join(root, "fleet.json"));
763
- out.push({ name, root, status: "unknown", created_at: "" });
764
- } catch {
765
- // not a fleet root
766
- }
767
- }
768
- }
769
- out.sort((a, b) => b.created_at.localeCompare(a.created_at));
770
- return out;
771
- }
772
-
773
851
  export interface CanvasServer {
774
852
  url: string;
775
853
  port: number;
@@ -824,6 +902,14 @@ export async function startCanvasServer(opts: {
824
902
  }
825
903
  const m = url.pathname.match(/^\/api\/session\/([a-z0-9][a-z0-9-]*)$/);
826
904
  if (m) {
905
+ const isDemo = url.searchParams.get("demo") === "1";
906
+ let workerTask: string | undefined;
907
+ if (isDemo) {
908
+ const demo = buildDemoSession(m[1]);
909
+ res.writeHead(200, { "content-type": "application/json" });
910
+ res.end(JSON.stringify(demo));
911
+ return;
912
+ }
827
913
  const f = await resolveFleet(url.searchParams.get("fleet"));
828
914
  if (!f || f === "unknown" || !f.spec.workers.some((w) => w.id === m[1])) {
829
915
  res.writeHead(404);
@@ -835,12 +921,14 @@ export async function startCanvasServer(opts: {
835
921
  const file = await latestSessionFile(join(f.fleetRoot, "workers", m[1]));
836
922
  res.writeHead(200, { "content-type": "application/json" });
837
923
  const worker = f.spec.workers.find((w) => w.id === m[1]);
924
+ workerTask = worker?.task;
838
925
  if (!file) {
839
- res.end(JSON.stringify({ entries: [], task: worker?.task }));
926
+ res.end(JSON.stringify({ entries: [], actions: [], events: [], task: workerTask }));
840
927
  return;
841
928
  }
842
929
  const content = await readFile(file, "utf-8");
843
- res.end(JSON.stringify({ entries: parseSessionTail(content, tail), task: worker?.task }));
930
+ const { entries, actions, events } = parseSessionTail(content, tail);
931
+ res.end(JSON.stringify({ entries, actions, events, task: workerTask }));
844
932
  return;
845
933
  }
846
934
  res.writeHead(404);
package/src/command.ts CHANGED
@@ -1,19 +1,19 @@
1
- import { writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, stopCanvas, updateWidget } from "./controller.js";
2
+ import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
+ import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
5
4
  import { openInBrowser, listFleetRoots } from "./canvas.js";
6
5
  import { insertWorkers } from "./insert.js";
7
6
  import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
8
- import { resolveModelReference } from "./model-resolution.js";
7
+ import { listModelRefs, resolveModelReference } from "./model-resolution.js";
9
8
  import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
9
+ import { recoverLatestFleet } from "./fleet-recovery.js";
10
10
  import { resetForRelaunch, writeState } from "./state.js";
11
11
  import { buildWidgetLines } from "./ui.js";
12
12
  import { renderDag } from "./viz.js";
13
13
 
14
14
  export function registerFleetCommand(pi: ExtensionAPI): void {
15
15
  pi.registerCommand("fleet", {
16
- description: "Fleet commands: /fleet viz, /fleet status, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet relaunch <node_id> [model]",
16
+ description: "Fleet commands: /fleet viz, /fleet status, /fleet models, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet continue, /fleet relaunch <node_id> [model]",
17
17
  handler: async (args, ctx) => {
18
18
  const [cmd, target] = args.trim().split(/\s+/);
19
19
  if (cmd === "configure") {
@@ -32,7 +32,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
32
32
  return;
33
33
  }
34
34
  const prefs = await loadPreferences();
35
- const r = setPreference(prefs, key, value);
35
+ const r = setPreference(prefs, key, value, ctx.modelRegistry);
36
36
  if (!r.ok) {
37
37
  ctx.ui.notify(r.error, "error");
38
38
  return;
@@ -50,15 +50,19 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
50
50
  const key = field.split(":")[0] as (typeof PREFERENCE_KEYS)[number];
51
51
  const input = await ctx.ui.input(`${key} (current: ${prefs[key] ?? "—"}):`, "empty clears");
52
52
  if (input === undefined) break;
53
+ let err: string | undefined;
53
54
  const next = input.trim().length === 0
54
55
  ? clearPreference(prefs, key)
55
56
  : (() => {
56
- const r = setPreference(prefs, key, input.trim());
57
- if (!r.ok) return undefined;
57
+ const r = setPreference(prefs, key, input.trim(), ctx.modelRegistry);
58
+ if (!r.ok) {
59
+ err = r.error;
60
+ return undefined;
61
+ }
58
62
  return r.prefs;
59
63
  })();
60
64
  if (next === undefined) {
61
- ctx.ui.notify(`invalid value for ${key}`, "error");
65
+ ctx.ui.notify(err ?? `invalid value for ${key}`, "error");
62
66
  continue;
63
67
  }
64
68
  await savePreferences(next);
@@ -87,26 +91,36 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
87
91
  ctx.ui.notify(`fleet canvas: ${url}`, "info");
88
92
  return;
89
93
  }
90
- const active = activeFleet.current;
94
+ if (cmd === "models") {
95
+ ctx.ui.notify(`available models:\n${listModelRefs(ctx.modelRegistry).join("\n")}`, "info");
96
+ return;
97
+ }
98
+ const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
99
+ if (active) activeFleet.current ??= active;
91
100
  if (!active) {
92
101
  ctx.ui.notify("no fleet planned yet", "warning");
93
102
  return;
94
103
  }
95
104
  if (cmd === "viz") {
105
+ active.widgetVisible = true;
96
106
  const lines = renderDag(active.spec, active.state).split("\n");
97
107
  ctx.ui.setWidget("fleet", lines);
108
+ ctx.ui.notify("fleet widget visible; fleet canvas link: " + (await ensureCanvas(ctx)).url, "info");
98
109
  return;
99
110
  }
100
111
  if (cmd === "status" || cmd === "") {
101
- ctx.ui.setWidget("fleet", buildWidgetLines(active.spec, active.state));
112
+ const server = await ensureCanvas(ctx);
113
+ ctx.ui.notify(`${await statusText(active)}\n\nfleet canvas: ${server.url}`, "info");
102
114
  return;
103
115
  }
104
116
  if (cmd === "clear") {
117
+ active.widgetVisible = false;
105
118
  ctx.ui.setWidget("fleet", []);
119
+ ctx.ui.notify("fleet widget hidden", "info");
106
120
  return;
107
121
  }
108
122
  if (cmd === "kill") {
109
- const text = await killFleet(target ?? "");
123
+ const text = await killFleet(target ?? "", ctx.cwd);
110
124
  const severity = text.includes("kill") && !text.startsWith("unknown") && !text.includes("already") ? "warning" : "error";
111
125
  ctx.ui.notify(text, severity);
112
126
  return;
@@ -141,6 +155,33 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
141
155
  ctx.ui.notify("fleet resumed", "info");
142
156
  return;
143
157
  }
158
+ if (cmd === "continue") {
159
+ if (active.running) {
160
+ ctx.ui.notify("fleet already running", "warning");
161
+ return;
162
+ }
163
+ await currentState(active);
164
+ if (active.state.status === "completed") {
165
+ ctx.ui.notify("fleet completed, nothing to continue", "warning");
166
+ return;
167
+ }
168
+ if (active.state.status === "paused") {
169
+ ctx.ui.notify("fleet is paused; use /fleet resume for paused loop fleets", "warning");
170
+ return;
171
+ }
172
+ if (active.state.status === "planned" && Object.values(active.state.nodes).every((n) => n.status === "pending")) {
173
+ ctx.ui.notify("fleet has not started; use /fleet launch", "warning");
174
+ return;
175
+ }
176
+ active.killSwitch.killed = false;
177
+ active.pauseSwitch.paused = false;
178
+ active.state = { ...active.state, status: "running", paused: false };
179
+ await writeState(active.fleetRoot, active.state);
180
+ await writeWorkerPrompts(active);
181
+ void startLoop(active, ctx, false, true);
182
+ ctx.ui.notify("fleet continue requested", "info");
183
+ return;
184
+ }
144
185
  if (cmd === "relaunch") {
145
186
  if (!target) {
146
187
  ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
@@ -175,10 +216,11 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
175
216
  }
176
217
  const canonical = `${resolved.model.provider}/${resolved.model.id}`;
177
218
  active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
178
- await writeFile(join(active.fleetRoot, "fleet.json"), `${JSON.stringify(active.spec, null, 2)}\n`, "utf-8");
219
+ await persistFleetJson(active);
179
220
  }
180
221
  active.state = resetForRelaunch(active.state, active.spec, target);
181
222
  await writeState(active.fleetRoot, active.state);
223
+ await writeWorkerPrompts(active);
182
224
  prepareRelaunch(active, target);
183
225
  void startLoop(active, ctx, false, true);
184
226
  ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
@@ -241,7 +283,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
241
283
  if (r.ok) updateWidget(ctx, active);
242
284
  return;
243
285
  }
244
- ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet relaunch <node_id> [model]", "warning");
286
+ ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet models | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet continue | /fleet relaunch <node_id> [model]", "warning");
245
287
  },
246
288
  });
247
289
  }
package/src/contracts.ts CHANGED
@@ -4,53 +4,91 @@ import type { ContractCheck, ContractOutput, ContractResult, Verdict } from "./t
4
4
 
5
5
  export const VERDICT_RE = /^verdict:\s*(lgtm|iterate|escalate)\s*$/mi;
6
6
 
7
+ // Leading metadata lines that a markdown file may place before its first heading.
8
+ const MARKDOWN_METADATA_RE = /^(status|verdict):\s*(approved|needs-revision|escalate|lgtm|iterate)\s*$/i;
9
+
7
10
  function resolvePath(workerDir: string, repoCwd: string, p: string): string {
8
11
  if (isAbsolute(p)) return p;
9
12
  return p.startsWith("output/") ? join(workerDir, p) : join(repoCwd, p);
10
13
  }
11
14
 
15
+ function firstLines(content: string, n = 5): string {
16
+ return content
17
+ .split("\n")
18
+ .slice(0, n)
19
+ .map((l) => l.trimEnd())
20
+ .join(" / ");
21
+ }
22
+
12
23
  async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
13
24
  const full = resolvePath(workerDir, repoCwd, o.path);
14
- const fail = (error: string): ContractCheck => ({ path: o.path, kind: o.kind, required: o.required, ok: false, error });
25
+ const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
26
+ const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
15
27
  let content: string;
16
28
  try {
17
29
  const s = await stat(full);
18
30
  if (o.kind === "file-exists") {
19
31
  return s.size > 0
20
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
32
+ ? { ...base, ok: true }
21
33
  : fail("empty file");
22
34
  }
23
35
  content = await readFile(full, "utf-8");
24
36
  } catch {
25
- return fail("file not found");
37
+ return { path: o.path, kind: o.kind, required: o.required, ok: false, error: "file not found" };
26
38
  }
39
+
27
40
  switch (o.kind) {
28
41
  case "markdown": {
29
- const first = content.split("\n").find((l) => l.trim().length > 0) ?? "";
30
- return first.startsWith("#")
31
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
32
- : fail("no markdown heading");
42
+ const meaningful = content.split("\n").filter((l) => l.trim().length > 0);
43
+ const first10 = meaningful.slice(0, 10);
44
+ const heading = first10.find((l) => l.trim().startsWith("#"));
45
+ if (heading) {
46
+ return { ...base, ok: true };
47
+ }
48
+ // Check whether the only blocker was a leading metadata line like Status: ...
49
+ const metadataFirst = first10[0]?.match(MARKDOWN_METADATA_RE);
50
+ return { ...fail(metadataFirst ? "no markdown heading after leading metadata line" : "no markdown heading"), firstLines: firstLines(content) };
33
51
  }
34
52
  case "verdict": {
35
53
  const m = content.match(VERDICT_RE);
36
- if (!m) return fail("no verdict line");
54
+ if (!m) return { ...base, ok: false, error: "no verdict line", firstLines: firstLines(content) };
37
55
  const body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
38
56
  return body.length > 0
39
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
40
- : fail("verdict line without body");
57
+ ? { ...base, ok: true }
58
+ : { ...base, ok: false, error: "verdict line without body", firstLines: firstLines(content) };
41
59
  }
42
- case "json":
60
+ case "json": {
61
+ let parsed: unknown;
43
62
  try {
44
- JSON.parse(content);
45
- return { path: o.path, kind: o.kind, required: o.required, ok: true };
63
+ parsed = JSON.parse(content);
46
64
  } catch (e) {
47
- return fail(`json parse error: ${(e as Error).message}`);
65
+ return { ...base, ok: false, error: `json parse error: ${(e as Error).message}`, firstLines: firstLines(content) };
48
66
  }
67
+ if (o.schema) {
68
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
69
+ return { ...base, ok: false, error: "json schema requires an object", firstLines: firstLines(content) };
70
+ }
71
+ const obj = parsed as Record<string, unknown>;
72
+ for (const key of o.schema.required_keys ?? []) {
73
+ if (!Object.hasOwn(obj, key)) {
74
+ return { ...base, ok: false, error: `missing required key "${key}"`, firstLines: firstLines(content) };
75
+ }
76
+ }
77
+ for (const key of o.schema.number_keys ?? []) {
78
+ const v = obj[key];
79
+ const ok = typeof v === "number" || (Array.isArray(v) && v.every((x) => typeof x === "number"));
80
+ if (!ok) {
81
+ return { ...base, ok: false, error: `key "${key}" must be a number or number[]`, firstLines: firstLines(content) };
82
+ }
83
+ }
84
+ }
85
+ return { ...base, ok: true };
86
+ }
49
87
  case "yaml": {
50
88
  const bad = content.split("\n").some((l) => l.includes("\t"));
51
89
  return content.trim().length > 0 && !bad
52
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
53
- : fail("empty or invalid yaml");
90
+ ? { ...base, ok: true }
91
+ : { ...base, ok: false, error: "empty or invalid yaml" };
54
92
  }
55
93
  default:
56
94
  return fail(`unknown kind`);
@@ -76,3 +114,17 @@ export async function verifyOutputs(opts: {
76
114
  const verdict_body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
77
115
  return verdict_body.length > 0 ? { ok, checks, verdict, verdict_body } : { ok, checks };
78
116
  }
117
+
118
+ /** Format a concise, actionable note for the first failed required contract. */
119
+ export function contractFailureNote(checks: ContractCheck[]): string {
120
+ const failed = checks.find((c) => c.required && !c.ok);
121
+ if (!failed) return "contract failed";
122
+ const parts = [
123
+ `${failed.path}: ${failed.error}`,
124
+ `actual: ${failed.actualPath ?? "(not found)"}`,
125
+ ];
126
+ if (failed.firstLines) {
127
+ parts.push(`first lines: ${failed.firstLines}`);
128
+ }
129
+ return parts.join("; ");
130
+ }