pi-agent-fleet 0.4.0 → 0.6.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();
@@ -528,7 +628,7 @@ async function buildClientBundle(): Promise<string> {
528
628
  platform: "browser",
529
629
  target: "es2020",
530
630
  jsx: "automatic",
531
- minify: true,
631
+ minify: false,
532
632
  write: false,
533
633
  logLevel: "silent",
534
634
  define: { "process.env.NODE_ENV": '"production"' },
@@ -626,14 +726,52 @@ 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
+ .note, .fail-reason { user-select:text; cursor:text; }
730
+ #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column; background:var(--bg); }
630
731
  #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); }
732
+ .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); }
733
+ .side-head .side-meta { display:flex; gap:6px; width:100%; }
734
+ .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; }
735
+ .side-head .meta { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:4px; }
736
+ .side-head .side-hash { color:var(--muted); font-weight:400; }
737
+ .side-head .side-id { font-weight:700; color:var(--hdr); font-size:15px; letter-spacing:-0.01em; }
738
+ .side-body { flex:1; overflow:auto; padding:8px 12px 12px; }
633
739
  .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
740
  .msg { margin-bottom:10px; padding:8px; border-radius:6px; background:var(--panel); white-space:pre-wrap; word-break:break-word; }
635
741
  .msg .role { font-weight:700; margin-bottom:4px; }
636
742
  .role-user { color:var(--accent); } .role-assistant { color:var(--ok); } .role-tool { color:var(--warn); }
743
+ .collapsible { position:sticky; top:8px; z-index:1; border:1px solid var(--line); border-radius:6px; margin-bottom:10px; background:var(--bg); }
744
+ .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; }
745
+ .collapsible-head:hover { background:var(--panel); }
746
+ .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; }
747
+ .collapsible.collapsed .collapsible-body { display:none; }
748
+ .collapsible .chevron { display:inline-block; transition:transform .15s; }
749
+ .collapsible.collapsed .chevron { transform:rotate(-90deg); }
750
+ .timeline { margin-bottom:12px; }
751
+ .timeline-action { background:var(--panel); border-radius:5px; margin-bottom:5px; }
752
+ .timeline-action.open { background:var(--panel-2); }
753
+ .timeline-action.action-error { border-left:3px solid var(--bad); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
754
+ .timeline-row { display:flex; align-items:center; gap:8px; padding:5px 8px; font-size:12px; }
755
+ .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; }
756
+ .timeline-row .action-name { flex-shrink:0; font-weight:600; min-width:48px; font-size:12px; white-space:nowrap; }
757
+ .timeline-row .action-detail { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
758
+ .timeline-row .action-error { color:var(--bad); }
759
+ .timeline-row .ts { flex-shrink:0; color:var(--wire); font-size:11px; font-variant-numeric:tabular-nums; }
760
+ .timeline-msg { padding:4px 8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
761
+ .timeline-msg.user { border-left:1px solid var(--accent); background:color-mix(in srgb, var(--accent) 5%, var(--panel)); }
762
+ .timeline-msg.assistant { border-left:1px solid var(--ok); background:color-mix(in srgb, var(--ok) 5%, var(--panel)); }
763
+ .timeline-msg .role { font-weight:700; margin-bottom:4px; }
764
+ .timeline-msg .timeline-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
765
+ .timeline-msg .timeline-meta .ts { color:var(--wire); font-size:11px; }
766
+ .timeline-msg .timeline-text { font-size:12px; line-height:1.45; white-space:pre-wrap; word-break:break-word; user-select:text; }
767
+ .timeline-loading { display:flex; align-items:center; justify-content:center; gap:8px; padding:16px 8px; color:var(--muted); font-size:13px; }
768
+ .timeline-empty { padding:16px 8px; color:var(--muted); font-size:13px; text-align:center; }
769
+ .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; }
770
+ .activity-toggle:hover { background:var(--bg); color:var(--fg); }
771
+ .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; user-select:text; }
772
+ .activity-body pre { margin:0; background:var(--bg); padding:8px; border-radius:4px; overflow:auto; font-size:11px; }
773
+ .timeline-more { margin-top:4px; padding:0; min-height:auto; border:none; background:none; color:var(--accent); font-size:11px; }
774
+ .timeline-more:hover { text-decoration:underline; }
637
775
  .react-flow__minimap-node.st-completed { fill:var(--ok); }
638
776
  .react-flow__minimap-node.st-running { fill:var(--accent); }
639
777
  .react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
@@ -677,6 +815,7 @@ button.toggled { border-color:var(--accent); color:var(--accent); }
677
815
  .empty-cta:hover { background:var(--panel); }
678
816
  /* failure reason surfaced on failed cards */
679
817
  .fail-reason { margin-top:6px; font-size:13px; color:var(--bad); }
818
+ .action-detail { user-select:text; }
680
819
  /* respect reduced-motion: keep the state, drop the perpetual movement */
681
820
  @media (prefers-reduced-motion: reduce) {
682
821
  .spinner { animation:none; border-top-color:var(--accent); opacity:0.6; }
@@ -713,63 +852,6 @@ export async function renderCanvasPage(): Promise<string> {
713
852
  </html>`;
714
853
  }
715
854
 
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
855
  export interface CanvasServer {
774
856
  url: string;
775
857
  port: number;
@@ -824,6 +906,14 @@ export async function startCanvasServer(opts: {
824
906
  }
825
907
  const m = url.pathname.match(/^\/api\/session\/([a-z0-9][a-z0-9-]*)$/);
826
908
  if (m) {
909
+ const isDemo = url.searchParams.get("demo") === "1";
910
+ let workerTask: string | undefined;
911
+ if (isDemo) {
912
+ const demo = buildDemoSession(m[1]);
913
+ res.writeHead(200, { "content-type": "application/json" });
914
+ res.end(JSON.stringify(demo));
915
+ return;
916
+ }
827
917
  const f = await resolveFleet(url.searchParams.get("fleet"));
828
918
  if (!f || f === "unknown" || !f.spec.workers.some((w) => w.id === m[1])) {
829
919
  res.writeHead(404);
@@ -835,12 +925,14 @@ export async function startCanvasServer(opts: {
835
925
  const file = await latestSessionFile(join(f.fleetRoot, "workers", m[1]));
836
926
  res.writeHead(200, { "content-type": "application/json" });
837
927
  const worker = f.spec.workers.find((w) => w.id === m[1]);
928
+ workerTask = worker?.task;
838
929
  if (!file) {
839
- res.end(JSON.stringify({ entries: [], task: worker?.task }));
930
+ res.end(JSON.stringify({ entries: [], actions: [], events: [], task: workerTask }));
840
931
  return;
841
932
  }
842
933
  const content = await readFile(file, "utf-8");
843
- res.end(JSON.stringify({ entries: parseSessionTail(content, tail), task: worker?.task }));
934
+ const { entries, actions, events } = parseSessionTail(content, tail);
935
+ res.end(JSON.stringify({ entries, actions, events, task: workerTask }));
844
936
  return;
845
937
  }
846
938
  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, requestRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
+ import { 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 } from "./model-resolution.js";
9
8
  import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
10
- import { resetForRelaunch, writeState } from "./state.js";
9
+ import { recoverLatestFleet } from "./fleet-recovery.js";
10
+ import { 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,47 +155,47 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
141
155
  ctx.ui.notify("fleet resumed", "info");
142
156
  return;
143
157
  }
144
- if (cmd === "relaunch") {
145
- if (!target) {
146
- ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
147
- return;
148
- }
158
+ if (cmd === "continue") {
149
159
  if (active.running) {
150
- ctx.ui.notify("fleet is running", "warning");
160
+ ctx.ui.notify("fleet already running", "warning");
151
161
  return;
152
162
  }
153
163
  await currentState(active);
154
164
  if (active.state.status === "completed") {
155
- ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
165
+ ctx.ui.notify("fleet completed, nothing to continue", "warning");
156
166
  return;
157
167
  }
158
- const worker = active.spec.workers.find((w) => w.id === target);
159
- if (!worker) {
160
- ctx.ui.notify(`unknown node "${target}"`, "warning");
168
+ if (active.state.status === "paused") {
169
+ ctx.ui.notify("fleet is paused; use /fleet resume for paused loop fleets", "warning");
161
170
  return;
162
171
  }
163
- const node = active.state.nodes[target];
164
- const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
165
- if (!node || !relaunchable.has(node.status)) {
166
- ctx.ui.notify(`node "${target}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`, "warning");
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");
167
174
  return;
168
175
  }
169
- const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
170
- if (model) {
171
- const resolved = resolveModelReference(ctx.modelRegistry, model);
172
- if (!resolved.ok) {
173
- ctx.ui.notify(resolved.error, "error");
174
- return;
175
- }
176
- const canonical = `${resolved.model.provider}/${resolved.model.id}`;
177
- 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");
179
- }
180
- active.state = resetForRelaunch(active.state, active.spec, target);
176
+ active.killSwitch.killed = false;
177
+ active.pauseSwitch.paused = false;
178
+ active.state = { ...active.state, status: "running", paused: false };
181
179
  await writeState(active.fleetRoot, active.state);
182
- prepareRelaunch(active, target);
180
+ await writeWorkerPrompts(active);
183
181
  void startLoop(active, ctx, false, true);
184
- ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
182
+ ctx.ui.notify("fleet continue requested", "info");
183
+ return;
184
+ }
185
+ if (cmd === "relaunch") {
186
+ if (!target) {
187
+ ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
188
+ return;
189
+ }
190
+ await currentState(active);
191
+ if (active.state.status === "completed") {
192
+ ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
193
+ return;
194
+ }
195
+ const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
196
+ const result = await requestRelaunch(active, target, model, ctx.modelRegistry);
197
+ if (result.startNow) void startLoop(active, ctx, false, true);
198
+ ctx.ui.notify(result.message, result.startNow ? "info" : result.message.startsWith("relaunch queued") ? "info" : "warning");
185
199
  return;
186
200
  }
187
201
  if (cmd === "add") {
@@ -241,7 +255,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
241
255
  if (r.ok) updateWidget(ctx, active);
242
256
  return;
243
257
  }
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");
258
+ 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
259
  },
246
260
  });
247
261
  }