pi-agent-fleet 0.2.0 → 0.4.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/ui.ts CHANGED
@@ -1,30 +1,69 @@
1
- import type { FleetSpec, FleetState, NodeStatus } from "./types.js";
1
+ import type { FleetSpec, FleetState, NodeState, NodeStatus, WorkerSpec } from "./types.js";
2
+
3
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
4
+ export const DEFAULT_MAX_LINES = 12;
2
5
 
3
6
  const ICON: Record<NodeStatus, string> = {
4
7
  completed: "✓", failed: "✗", contract_failed: "✗",
5
8
  running: "⠹", blocked: "⊘", killed: "⊘", pending: "○", ready: "○",
6
9
  };
7
10
 
8
- export function buildWidgetLines(spec: FleetSpec, state: FleetState): string[] {
9
- const done = spec.workers.filter((w) => state.nodes[w.id].status === "completed").length;
11
+ const DETAIL_STATUSES: ReadonlySet<NodeStatus> = new Set(["running", "completed", "failed", "contract_failed"]);
12
+ const ATTENTION_STATUSES: ReadonlySet<NodeStatus> = new Set(["running", "failed", "contract_failed", "killed", "blocked"]);
13
+
14
+ export interface WidgetOpts {
15
+ maxLines?: number;
16
+ spinnerFrame?: number;
17
+ }
18
+
19
+ export function buildWidgetLines(spec: FleetSpec, state: FleetState, opts: WidgetOpts = {}): string[] {
20
+ const maxLines = Math.max(opts.maxLines ?? DEFAULT_MAX_LINES, 3);
21
+ const done = spec.workers.filter((w) => state.nodes[w.id]?.status === "completed").length;
10
22
  const loop = spec.config.loop;
11
23
  let header: string;
12
24
  if (loop) {
13
25
  const lgtmCount = loop.lgtm_count ?? 1;
14
26
  const lastVerdict = state.iterations.length > 0 ? state.iterations[state.iterations.length - 1].verdict : null;
15
- const streakSegment = loop.gate === "reviewer" ? ` · streak ${state.lgtm_streak}/${lgtmCount}` : "";
27
+ const streakSegment = loop.gate === "reviewer" ? ` · lgtm streak ${state.lgtm_streak}/${lgtmCount}` : "";
16
28
  header = `● fleet: ${spec.fleet_name} · iteration ${state.iteration}/${loop.max_iterations} · last verdict: ${lastVerdict ?? "—"}${streakSegment} (${done}/${spec.workers.length} done · $${state.cost_usd_estimate.toFixed(2)})`;
17
29
  } else {
18
30
  header = `● fleet: ${spec.fleet_name} (${done}/${spec.workers.length} done · $${state.cost_usd_estimate.toFixed(2)})`;
19
31
  }
20
- const lines = [header];
21
- spec.workers.forEach((w, i) => {
22
- const n = state.nodes[w.id];
23
- const branch = i === spec.workers.length - 1 ? "└─" : "├─";
24
- const detail = n.status === "running" ? ` · ${n.turns} turns · ${(n.tokens / 1000).toFixed(1)}k tok` : "";
32
+
33
+ const icon = (s: NodeStatus): string =>
34
+ s === "running" && opts.spinnerFrame !== undefined
35
+ ? SPINNER_FRAMES[opts.spinnerFrame % SPINNER_FRAMES.length]
36
+ : ICON[s];
37
+
38
+ const line = (w: WorkerSpec, branch: string): string => {
39
+ const n: NodeState = state.nodes[w.id] ?? { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
40
+ // loop snapshots zero live per-node cost (archived into iteration totals) — fall back to the last snapshot so completed nodes keep their cost visible
41
+ const lastIter = state.iterations.length > 0 ? state.iterations[state.iterations.length - 1] : undefined;
42
+ const cost = n.cost_usd_estimate > 0 || n.status === "running"
43
+ ? n.cost_usd_estimate
44
+ : (lastIter?.nodes[w.id]?.cost_usd_estimate ?? n.cost_usd_estimate);
45
+ const detail = DETAIL_STATUSES.has(n.status)
46
+ ? ` · ${n.turns} turns · ${(n.tokens / 1000).toFixed(1)}k tok · $${cost.toFixed(2)}`
47
+ : "";
25
48
  const note = n.status_note ? ` · ${n.status_note}` : "";
26
- const model = w.model ?? spec.config.model;
27
- lines.push(`${branch} ${ICON[n.status]} ${w.id} (${model})${detail}${note}`);
28
- });
49
+ const model = w.model ?? spec.config.model ?? "(default)";
50
+ return `${branch} ${icon(n.status)} ${w.id} (${model})${detail}${note}`;
51
+ };
52
+
53
+ const budget = Math.max(maxLines - 1, 1);
54
+ if (spec.workers.length <= budget) {
55
+ const lines = [header];
56
+ spec.workers.forEach((w, i) => {
57
+ lines.push(line(w, i === spec.workers.length - 1 ? "└─" : "├─"));
58
+ });
59
+ return lines;
60
+ }
61
+
62
+ const attention = spec.workers.filter((w) => ATTENTION_STATUSES.has(state.nodes[w.id]?.status ?? "pending"));
63
+ const rest = spec.workers.filter((w) => !ATTENTION_STATUSES.has(state.nodes[w.id]?.status ?? "pending"));
64
+ const visible = [...attention, ...rest].slice(0, Math.max(budget - 1, 1));
65
+ const hidden = spec.workers.length - visible.length;
66
+ const lines = [header, ...visible.map((w) => line(w, "├─"))];
67
+ lines.push(`└─ … +${hidden} more (${done}/${spec.workers.length} done)`);
29
68
  return lines;
30
69
  }
package/src/viz.ts CHANGED
@@ -11,11 +11,12 @@ export function renderDag(spec: FleetSpec, state?: FleetState): string {
11
11
  const layers = topoLayers(spec);
12
12
  const modelOf = (id: string): string => {
13
13
  const w = spec.workers.find((x) => x.id === id);
14
- return w?.model ?? spec.config.model;
14
+ return w?.model ?? spec.config.model ?? "(default)";
15
15
  };
16
16
  const label = (id: string): string => {
17
17
  const st = state?.nodes[id]?.status;
18
- const base = `${id} (${modelOf(id)})`;
18
+ const model = modelOf(id);
19
+ const base = model === "(default)" ? `${id} ${model}` : `${id} (${model})`;
19
20
  return st ? `${ICON[st]} ${base}` : base;
20
21
  };
21
22
  const lines: string[] = [];
@@ -0,0 +1,75 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileP = promisify(execFile);
7
+
8
+ export interface CreateWorktreeOpts {
9
+ baseRepo: string;
10
+ fleetName: string;
11
+ nodeId: string;
12
+ fleetRoot: string;
13
+ }
14
+
15
+ export async function createWorktree(opts: CreateWorktreeOpts): Promise<string> {
16
+ const path = join(opts.fleetRoot, "worktrees", opts.nodeId);
17
+ const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
18
+ await mkdir(path, { recursive: true });
19
+ await removeWorktree(path, opts.baseRepo);
20
+ await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
21
+ return path;
22
+ }
23
+
24
+ export interface CommitWorktreeOpts {
25
+ worktreePath: string;
26
+ nodeId: string;
27
+ fleetName: string;
28
+ iteration: number;
29
+ }
30
+
31
+ export async function commitWorktree(opts: CommitWorktreeOpts): Promise<void> {
32
+ await execFileP("git", ["add", "-A"], { cwd: opts.worktreePath });
33
+ try {
34
+ await execFileP(
35
+ "git",
36
+ ["commit", "-m", `fleet: ${opts.fleetName} ${opts.nodeId} iteration ${opts.iteration}`],
37
+ { cwd: opts.worktreePath },
38
+ );
39
+ } catch (e) {
40
+ const msg = e instanceof Error ? e.message : String(e);
41
+ if (msg.includes("nothing to commit")) return;
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ export interface PrepareIntegratorOpts {
47
+ baseRepo: string;
48
+ fleetName: string;
49
+ fleetRoot: string;
50
+ branches: string[];
51
+ }
52
+
53
+ export async function prepareIntegratorWorktree(
54
+ opts: PrepareIntegratorOpts,
55
+ ): Promise<{ path: string; ok: boolean; conflict?: string }> {
56
+ const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
57
+ await removeWorktree(path, opts.baseRepo);
58
+ await mkdir(path, { recursive: true });
59
+ await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
60
+ cwd: opts.baseRepo,
61
+ });
62
+ for (const branch of opts.branches) {
63
+ try {
64
+ await execFileP("git", ["merge", "--no-ff", "--no-edit", branch], { cwd: path });
65
+ } catch (e) {
66
+ const msg = e instanceof Error ? e.message : String(e);
67
+ return { path, ok: false, conflict: `merge ${branch} failed: ${msg}` };
68
+ }
69
+ }
70
+ return { path, ok: true };
71
+ }
72
+
73
+ export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
74
+ await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
75
+ }