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/README.md +23 -8
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +2 -2
- package/src/canvas-client.tsx +169 -18
- package/src/canvas.ts +173 -85
- package/src/command.ts +56 -14
- package/src/contracts.ts +68 -16
- package/src/controller.ts +25 -4
- package/src/dag.ts +32 -7
- package/src/edits.ts +8 -9
- package/src/fleet-recovery.ts +73 -0
- package/src/fleet-store.ts +11 -2
- package/src/insert.ts +2 -8
- package/src/model-resolution.ts +27 -2
- package/src/preferences.ts +10 -1
- package/src/prompts.ts +10 -0
- package/src/report.ts +28 -1
- package/src/scheduler.ts +3 -2
- package/src/tools.ts +67 -14
- package/src/types.ts +8 -0
- package/src/worktree.ts +6 -0
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
|
|
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 {
|
|
11
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
}
|
|
501
|
-
|
|
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
|
|
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:
|
|
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;
|
|
632
|
-
.side-head .meta {
|
|
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: [],
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
? {
|
|
32
|
+
? { ...base, ok: true }
|
|
21
33
|
: fail("empty file");
|
|
22
34
|
}
|
|
23
35
|
content = await readFile(full, "utf-8");
|
|
24
36
|
} catch {
|
|
25
|
-
return
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
? {
|
|
40
|
-
:
|
|
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
|
|
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
|
-
? {
|
|
53
|
-
:
|
|
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
|
+
}
|