Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### Features

- Show Pi background-job progress automatically above the input editor, including
active nodes, paused gates, failures, and retained final status. Keep running
executions visible after live edits remove their node definitions; use `/braid`
for the full flow panel.

## 0.3.1 — 2026-10-04

### Features
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ With Pi 1.0.1 and Node.js 22.19+:
pi install npm:@chrok/pi-braid
```

Run `/reload`, ask Pi to analyze a task with Braid, and open `/braid` to inspect
the job. npm installs the exact matching core dependency; no checkout is needed
Run `/reload` and ask Pi to analyze a task with Braid. Background progress appears
automatically above the input editor; open `/braid` for the full live panel.
npm installs the exact matching core dependency; no checkout is needed
for published versions. To try the current source checkout, follow the
[local Pi installation guide](integrations/pi/README.md#install-this-local-checkout-in-pi).

Expand Down
15 changes: 15 additions & 0 deletions integrations/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,21 @@ scheduler persistence across Pi reloads. Reminders alone do not pause execution.

## Live flow panel

In interactive Pi, background progress appears automatically **above the input
editor** as soon as a job is submitted; no command or polling is needed:

```text
Braid job-1 · running · 2/5 done · review
```

The widget shows node progress, active node IDs, paused gates, and failures.
It shows up to three active jobs, with an overflow count for the rest. When no
jobs are active, the newest job's final completed/failed/cancelled status stays
visible until another job is submitted or the session ends. Counts use the
latest state of each current node definition; failed and skipped nodes count as
done, and reruns or live edits can change progress and totals. RPC, JSON, and
print modes do not create a terminal widget.

Run `/braid` to open the newest job, or `/braid <jobId>` to open a specific job.
The bordered panel keeps the job header and keyboard controls visible while
you scroll the flow and event log. It refreshes as nodes start, finish, fail,
Expand Down
56 changes: 56 additions & 0 deletions integrations/pi/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,62 @@ export class BraidPanel implements Component {
}
}

export function registerBraidWidget(pi: ExtensionAPI, jobs: BraidJobs): () => void {
let dispose = () => {};
const clear = () => {
dispose();
dispose = () => {};
};
pi.on("session_start", (_event, ctx) => {
clear();
if (ctx.mode !== "tui") return;
const refresh = () => {
if (!jobs.list().length) {
ctx.ui.setWidget("braid-status", undefined);
return;
}
ctx.ui.setWidget("braid-status", (_tui, theme) => ({
render(width) {
const all = jobs.list();
const active = all.filter(job => job.status === "running");
const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
const lines = selected.map(job => {
const progress = jobs.progress(job.jobId)!;
const status = job.status === "running" && progress.paused && !progress.running.length
? "paused" : job.status;
const color = status === "completed" ? "success"
: status === "failed" ? "error"
: status === "cancelled" ? "muted" : "warning";
const parts = [
theme.fg("accent", `Braid ${job.handle}`),
theme.fg(color, status),
`${progress.done}/${progress.total} done`,
];
if (progress.failed) parts.push(theme.fg("error", `${progress.failed} failed`));
if (progress.paused && status !== "paused")
parts.push(theme.fg("warning", `${progress.paused} paused`));
parts.push(plain(progress.running.length ? progress.running.join(", ") : job.goal));
return truncateToWidth(parts.join(" · "), width, "…");
});
if (active.length > selected.length) lines.push(truncateToWidth(
theme.fg("muted", `Braid · +${active.length - selected.length} active jobs · /braid details`),
width, "…",
));
return lines;
},
invalidate() {},
}), { placement: "aboveEditor" });
};
const unsubscribe = jobs.subscribe(refresh);
dispose = () => {
unsubscribe();
ctx.ui.setWidget("braid-status", undefined);
};
refresh();
});
return clear;
}

export function registerBraidCommand(pi: ExtensionAPI, jobs: BraidJobs): void {
pi.registerCommand("braid", {
description: "Open the live background-job flow panel: /braid [jobId]",
Expand Down
4 changes: 3 additions & 1 deletion integrations/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import type { BraidInput, GraphUpdate, NodeResult } from "@chrok/braid";
import { Text } from "@earendil-works/pi-tui";
import { BraidJobs, type JobSnapshot } from "./jobs.js";
import { registerBraidCommand } from "./command.js";
import { registerBraidCommand, registerBraidWidget } from "./command.js";
import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";

const text = (description?: string) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
Expand Down Expand Up @@ -476,6 +476,7 @@ export default function braidExtension(pi: ExtensionAPI) {
});
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
registerBraidCommand(pi, jobs);
const disposeWidget = registerBraidWidget(pi, jobs);
pi.registerTool(braidTool);
pi.registerTool(statusTool);
pi.registerTool(cancelTool);
Expand All @@ -484,6 +485,7 @@ export default function braidExtension(pi: ExtensionAPI) {
pi.on("session_shutdown", () => {
pending.clear();
jobs.dispose();
disposeWidget();
});
pi.on("before_agent_start", (event) => ({
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
Expand Down
34 changes: 34 additions & 0 deletions integrations/pi/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ interface Job extends JobSnapshot {
done: Promise<void>;
usageClaimed: boolean;
nodeResults: Map<string, NodeResult>;
runningExecutions: Map<string, string>;
}

export interface NodeCompletion {
Expand Down Expand Up @@ -128,6 +129,7 @@ export class BraidJobs {
controller: new AbortController(),
done: Promise.resolve(),
usageClaimed: false,
runningExecutions: new Map(),
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
};
this.jobs.set(job.jobId, job);
Expand Down Expand Up @@ -180,6 +182,14 @@ export class BraidJobs {
value: { ...event.workspace }, enumerable: true, configurable: true, writable: true,
});
}
// Definition edits can remove nodes whose executions are still running.
if (event.type === "node_started" && event.executionId) {
job.runningExecutions.set(event.executionId, event.nodeId);
} else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
job.runningExecutions.delete(event.executionId);
} else if (event.type === "graph_completed" || event.type === "graph_failed") {
job.runningExecutions.clear();
}
applyEvent(job.live, event);
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
// The panel keeps short previews; node retrieval needs the complete
Expand Down Expand Up @@ -243,6 +253,7 @@ export class BraidJobs {
job.status = "failed";
job.live.status = "failed";
job.live.pausedExecutionIds = [];
job.runningExecutions.clear();
job.error = error instanceof Error ? error.message : String(error);
if (reports.length) job.usage = sumPiUsage(reports);
}
Expand Down Expand Up @@ -274,6 +285,7 @@ export class BraidJobs {
done: _done,
usageClaimed: _claimed,
nodeResults: _nodeResults,
runningExecutions: _runningExecutions,
...snapshot
} = job;
const copy = structuredClone(snapshot);
Expand Down Expand Up @@ -326,6 +338,28 @@ export class BraidJobs {
.reverse();
}

/** Editor progress without copying outputs, transcripts, or execution snapshots. */
progress(jobId: string): {
total: number;
done: number;
failed: number;
running: string[];
paused: number;
} | undefined {
const job = this.lookup(jobId);
if (!job) return undefined;
const nodes = Object.values(job.live.nodes);
return {
total: nodes.length,
done: nodes.filter(node =>
node.status === "completed" || node.status === "failed" || node.status === "skipped",
).length,
failed: nodes.filter(node => node.status === "failed").length,
running: [...new Set(job.runningExecutions.values())],
paused: job.live.pausedExecutionIds?.length ?? 0,
};
}

cancel(jobId: string): boolean {
const job = this.lookup(jobId);
if (!job) throw this.unknownJob(jobId);
Expand Down
17 changes: 17 additions & 0 deletions integrations/pi/test/execution-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu
assert.equal(paused.paused, true);
assert.ok(paused.executionId);
assert.equal(jobs.claimUsage(paused.handle), undefined);
assert.deepEqual(jobs.progress(paused.handle), {
total: 1, done: 1, failed: 0, running: [], paused: 1,
});
const receipt = JSON.parse((submitted.content[0] as { text: string }).text);
assert.deepEqual(receipt.graph.nodes, [{ type: "execute", id: "a", pauseAfter: true }]);
assert.deepEqual(receipt.graph.edges, []);
Expand All @@ -36,6 +39,7 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu
addEdges: [{ from: "a", executionId: paused.executionId, to: "b" }], resume: [paused.executionId],
}, undefined, undefined, ctx);
assert.equal(updated.details.execution!.revision, 1);
assert.equal(jobs.progress(paused.handle)!.total, 2);
const updateReceipt = JSON.parse((updated.content[0] as { text: string }).text);
assert.deepEqual(updateReceipt.graph.nodes, [{ type: "execute", id: "a" }, { type: "execute", id: "b" }]);
assert.deepEqual(updateReceipt.graph.edges, [{ from: "a", executionId: paused.executionId, to: "b" }]);
Expand All @@ -44,6 +48,9 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu
assert.equal(exact.output, "old result");
assert.equal(jobs.get(paused.handle)!.result!.executions[paused.executionId]!.node.prompt, "work");
assert.equal(jobs.get(paused.handle)!.result!.nodes.b!.status, "completed");
assert.deepEqual(jobs.progress(paused.handle), {
total: 2, done: 2, failed: 0, running: [], paused: 0,
});
await assert.rejects(tools.resumeTool.execute("late", { jobId: paused.handle, expectedRevision: 1, executionIds: [paused.executionId] }, undefined, undefined, ctx), /no longer/);
assert.ok(jobs.claimUsage(paused.handle));
assert.equal(jobs.claimUsage(paused.handle), undefined);
Expand All @@ -54,6 +61,12 @@ test("Pi loop reminders and status use a distinct execution ID for each iteratio
const ctx = context(async () => response());
t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => {
const payload = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string; execution: { iteration: number } };
if (payload.nodeId === "work" && payload.execution.iteration === 2) {
const progress = jobs.progress(jobs.list()[0]!.handle)!;
assert.equal(progress.total, 2);
assert.ok(progress.done < progress.total, "a rerun resets the current definition progress");
assert.deepEqual(progress.running, ["work"]);
}
if (payload.nodeId === "review" && worker.messages.length === 1) return {
...response(), stopReason: "toolUse" as const,
content: [{ type: "toolCall" as const, id: "choice", name: "decide", arguments: { choice: payload.execution.iteration === 2 ? "done" : "again" } }],
Expand All @@ -71,6 +84,10 @@ test("Pi loop reminders and status use a distinct execution ID for each iteratio
}, undefined, undefined, ctx);
await jobs.wait(job.details!.handle);
assert.equal(jobs.get(job.details!.handle)!.status, "completed");
assert.deepEqual(jobs.progress(job.details!.handle), {
total: 2, done: 2, failed: 0, running: [], paused: 0,
});
assert.equal(Object.keys(jobs.get(job.details!.handle)!.result!.executions).length, 4);
assert.deepEqual(notifications.map(value => value.iteration), [1, 2]);
assert.notEqual(notifications[0]!.executionId, notifications[1]!.executionId);
const first = await statusTool.execute("first", { jobId: job.details!.handle, executionId: notifications[0]!.executionId }, undefined, undefined, ctx);
Expand Down
26 changes: 26 additions & 0 deletions integrations/pi/test/selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ test("extension registers background tools, a panel command, and guidance to wai
["braid", "braid_status", "braid_cancel", "braid_update", "braid_resume"],
);
assert.ok(fake.commands.has("braid"));
assert.ok(fake.handlers.has("session_start"));
assert.equal(fake.handlers.has("input"), false);
assert.equal(fake.handlers.has("tool_call"), false);
const prompt = fake.handlers.get("before_agent_start")!(
Expand Down Expand Up @@ -261,3 +262,28 @@ test(
assert.equal(fake.reminders(), 0);
},
);

test("extension automatically shows editor job status and clears it on shutdown", { timeout: 3_000 }, async (t) => {
const fake = extension();
const started = deferred<void>();
const completion = deferred<ReturnType<typeof response>>();
const ctx = context(async () => { started.resolve(); return completion.promise; });
let widget: { render(width: number): string[] } | undefined;
ctx.ui = {
setWidget: (_key: string, factory: ((...args: unknown[]) => typeof widget) | undefined) => {
widget = factory?.({}, { fg: (_color: string, value: string) => value });
},
} as never;
t.after(() => fake.handlers.get("session_shutdown")!({} as never, ctx));
fake.handlers.get("session_start")!({} as never, ctx);
assert.equal(widget, undefined);
const tool = fake.tools.get("braid") as ReturnType<typeof createBraidTools>["braidTool"];
await tool.execute("call", input, undefined, undefined, ctx);
await started.promise;
assert.match(widget!.render(120).join("\n"), /Braid job-1 · running · 0\/1 done · a/);
completion.resolve(response());
await fake.reminder.promise;
assert.match(widget!.render(120).join("\n"), /completed · 1\/1 done/);
fake.handlers.get("session_shutdown")!({} as never, ctx);
assert.equal(widget, undefined);
});
Loading
Loading