@chrok/pi-braid 0.3.2 → 0.3.4
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 +85 -7
- package/dist/assistant.js +345 -0
- package/dist/command.js +428 -51
- package/dist/display.js +166 -19
- package/dist/human-input.js +60 -0
- package/dist/index.js +17 -3
- package/dist/jobs.js +78 -3
- package/dist/runner.js +109 -23
- package/dist/session-view.js +138 -0
- package/dist/shell-tools.js +12 -5
- package/dist/transcript.js +114 -0
- package/package.json +2 -2
package/dist/display.js
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
import { render as renderMermaid } from "grok-mermaid";
|
|
2
|
-
import { Text, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatActivityStatus, } from "@chrok/braid";
|
|
3
4
|
export const MAX_VISIBLE_EVENTS = 80;
|
|
4
5
|
export const MAX_VISIBLE_NODES = 80;
|
|
5
6
|
class FixedLines {
|
|
6
7
|
lines;
|
|
7
8
|
chartStart;
|
|
8
9
|
chartLength;
|
|
9
|
-
|
|
10
|
+
fallback;
|
|
11
|
+
focusMarks;
|
|
12
|
+
/**
|
|
13
|
+
* A too-wide chart is cropped to the terminal width around the first
|
|
14
|
+
* `focusMarks` entry drawn in it (the selected node, then a running node),
|
|
15
|
+
* or from its left edge. `fallback` (a node list while selecting) appears when
|
|
16
|
+
* there is no chart, or when the selected node cannot be found in it.
|
|
17
|
+
*/
|
|
18
|
+
constructor(lines, chartStart = lines.length, chartLength = 0, fallback = [], focusMarks = []) {
|
|
10
19
|
this.lines = lines;
|
|
11
20
|
this.chartStart = chartStart;
|
|
12
21
|
this.chartLength = chartLength;
|
|
22
|
+
this.fallback = fallback;
|
|
23
|
+
this.focusMarks = focusMarks;
|
|
13
24
|
}
|
|
14
25
|
render(width) {
|
|
15
26
|
const chart = this.lines.slice(this.chartStart, this.chartStart + this.chartLength);
|
|
16
|
-
|
|
27
|
+
const fallback = this.fallback.map((line) => truncateToWidth(line, width, ""));
|
|
28
|
+
if (chart.length === 0 && fallback.length === 0)
|
|
17
29
|
return this.lines.map((line) => truncateToWidth(line, width, ""));
|
|
18
|
-
const chartWidth = Math.max(...chart.map((line) => visibleWidth(line)));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const chartWidth = chart.length ? Math.max(...chart.map((line) => visibleWidth(line))) : 0;
|
|
31
|
+
let chartOutput = chart;
|
|
32
|
+
if (!chart.length)
|
|
33
|
+
chartOutput = fallback;
|
|
34
|
+
else if (chartWidth > width) {
|
|
35
|
+
const focus = this.focus(chart);
|
|
36
|
+
const start = focus === undefined ? 0 : Math.max(0, Math.min(chartWidth - width, focus.column - Math.floor(width / 2)));
|
|
37
|
+
chartOutput = [
|
|
38
|
+
truncateToWidth(`[chart ${start + 1}–${start + width} of ${chartWidth} cols${focus ? ` · ${focus.label}` : ""}]`, width, ""),
|
|
39
|
+
...chart.map((line) => sliceByColumn(line, start, width, true)),
|
|
40
|
+
// The first mark is the selection; list nodes if it is not drawn.
|
|
41
|
+
...(this.focusMarks.length && focus?.mark !== this.focusMarks[0].mark ? fallback : []),
|
|
23
42
|
];
|
|
43
|
+
}
|
|
24
44
|
const output = [];
|
|
25
45
|
let chartInserted = false;
|
|
26
46
|
for (let index = 0; index < this.lines.length; index++) {
|
|
@@ -37,6 +57,22 @@ class FixedLines {
|
|
|
37
57
|
output.push(...chartOutput);
|
|
38
58
|
return output;
|
|
39
59
|
}
|
|
60
|
+
/** The first focus mark drawn in the chart and its centre column. */
|
|
61
|
+
focus(chart) {
|
|
62
|
+
for (const { mark, label } of this.focusMarks) {
|
|
63
|
+
for (const line of chart) {
|
|
64
|
+
const plain = stripTerminalSequences(line);
|
|
65
|
+
const index = plain.indexOf(mark);
|
|
66
|
+
if (index < 0)
|
|
67
|
+
continue;
|
|
68
|
+
// Centre on the label: from the mark to the node's right border, when drawn.
|
|
69
|
+
const border = plain.indexOf("│", index);
|
|
70
|
+
const end = border < 0 ? index : border;
|
|
71
|
+
return { mark, label, column: Math.floor((visibleWidth(plain.slice(0, index)) + visibleWidth(plain.slice(0, end))) / 2) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
40
76
|
invalidate() { }
|
|
41
77
|
}
|
|
42
78
|
function record(value) {
|
|
@@ -121,7 +157,9 @@ function nodeElapsed(node, now) {
|
|
|
121
157
|
}
|
|
122
158
|
return "—";
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
/** Replaces the selected node's one-column status icon, so selection never moves the layout. */
|
|
161
|
+
export const SELECTED_NODE_MARK = "◆";
|
|
162
|
+
function mermaidLabelLines(id, result, selected = false) {
|
|
125
163
|
const progress = "progress" in result ? result.progress?.[id] : undefined;
|
|
126
164
|
const node = result.nodes[id];
|
|
127
165
|
if (!node)
|
|
@@ -140,7 +178,7 @@ function mermaidLabelLines(id, result) {
|
|
|
140
178
|
const now = "observedAt" in result ? result.observedAt : Date.now();
|
|
141
179
|
const iteration = "executions" in result ? result.executions[node.executionId ?? ""]?.iteration : result.iterations?.[id];
|
|
142
180
|
const lines = [
|
|
143
|
-
`${icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
181
|
+
`${selected ? SELECTED_NODE_MARK + icon.slice(1) : icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
144
182
|
`${status}${decision} · ${nodeElapsed(node, now)}`,
|
|
145
183
|
];
|
|
146
184
|
if (progress) {
|
|
@@ -152,7 +190,7 @@ function mermaidLabelLines(id, result) {
|
|
|
152
190
|
}
|
|
153
191
|
return lines;
|
|
154
192
|
}
|
|
155
|
-
function mermaidSource(result) {
|
|
193
|
+
function mermaidSource(result, selected) {
|
|
156
194
|
const parts = graphParts(result);
|
|
157
195
|
const ids = Object.keys(parts.nodes);
|
|
158
196
|
const names = new Map(ids.map((id, index) => [id, `n${index}`]));
|
|
@@ -173,15 +211,22 @@ function mermaidSource(result) {
|
|
|
173
211
|
for (const id of ids) {
|
|
174
212
|
const shape = parts.nodeTypes[id] === "decision" ? "{" : "[";
|
|
175
213
|
const close = parts.nodeTypes[id] === "decision" ? "}" : "]";
|
|
176
|
-
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
214
|
+
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
177
215
|
}
|
|
178
216
|
for (const edge of parts.edges) {
|
|
179
217
|
lines.push(` ${names.get(edge.from)} -->${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
|
|
180
218
|
}
|
|
181
219
|
return lines.join("\n");
|
|
182
220
|
}
|
|
183
|
-
function mermaidLines(result, theme) {
|
|
184
|
-
|
|
221
|
+
function mermaidLines(result, theme, selected) {
|
|
222
|
+
let art;
|
|
223
|
+
try {
|
|
224
|
+
art = renderMermaid(mermaidSource(result, selected));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// The chart is a view of state already listed elsewhere; never fail the panel for it.
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
185
230
|
if (!art)
|
|
186
231
|
return [];
|
|
187
232
|
return art.styled.map((row) => row
|
|
@@ -190,6 +235,8 @@ function mermaidLines(result, theme) {
|
|
|
190
235
|
case "border":
|
|
191
236
|
return theme.fg("borderMuted", span.text);
|
|
192
237
|
case "text":
|
|
238
|
+
if (selected !== undefined && span.text.includes(SELECTED_NODE_MARK))
|
|
239
|
+
return theme.bg("selectedBg", theme.bold(theme.fg("warning", span.text)));
|
|
193
240
|
return span.text.includes("▶ ACTIVE")
|
|
194
241
|
? theme.bg("selectedBg", theme.bold(theme.fg("accent", span.text)))
|
|
195
242
|
: theme.fg("text", span.text);
|
|
@@ -254,6 +301,8 @@ function eventText(event) {
|
|
|
254
301
|
return `handoff · ${compact(event.from, 40)} → ${compact(event.to, 40)}: ${compact(event.output, 64)}`;
|
|
255
302
|
case "node_started":
|
|
256
303
|
return `started · ${compact(event.nodeId, 40)}${event.model ? ` · ${compact(event.model, 50)}` : ""}`;
|
|
304
|
+
case "human_input":
|
|
305
|
+
return `human input · ${compact(event.nodeId, 40)}: ${compact(event.text, 64)}`;
|
|
257
306
|
case "node_completed":
|
|
258
307
|
return `completed · ${compact(event.nodeId, 40)}${event.decision ? ` → ${compact(event.decision, 30)}` : ""}: ${compact(event.output, 64)}`;
|
|
259
308
|
case "node_skipped":
|
|
@@ -282,7 +331,9 @@ function renderEventLog(events, expanded, theme) {
|
|
|
282
331
|
? "success"
|
|
283
332
|
: event.type === "node_started"
|
|
284
333
|
? "accent"
|
|
285
|
-
: "
|
|
334
|
+
: event.type === "human_input"
|
|
335
|
+
? "warning"
|
|
336
|
+
: "dim";
|
|
286
337
|
lines.push(theme.fg(color, ` ${String(event.sequence).padStart(3, "0")} ${eventText(event)}`));
|
|
287
338
|
}
|
|
288
339
|
return lines;
|
|
@@ -304,6 +355,8 @@ export function applyEvent(state, event) {
|
|
|
304
355
|
const preview = { ...event };
|
|
305
356
|
if ("output" in preview && preview.output !== undefined)
|
|
306
357
|
preview.output = compact(preview.output, 180);
|
|
358
|
+
if (preview.type === "human_input")
|
|
359
|
+
preview.text = compact(preview.text, 180);
|
|
307
360
|
if ("error" in preview)
|
|
308
361
|
preview.error = {
|
|
309
362
|
...preview.error,
|
|
@@ -416,6 +469,9 @@ export function applyEvent(state, event) {
|
|
|
416
469
|
node.skipReason = event.reason;
|
|
417
470
|
node.finishedAt = event.timestamp;
|
|
418
471
|
break;
|
|
472
|
+
case "human_input":
|
|
473
|
+
node.humanInputs = [...(node.humanInputs ?? []), { text: compact(event.text, 180), timestamp: event.timestamp }];
|
|
474
|
+
break;
|
|
419
475
|
}
|
|
420
476
|
}
|
|
421
477
|
export function applyProgress(state, progress) {
|
|
@@ -428,8 +484,46 @@ export function applyProgress(state, progress) {
|
|
|
428
484
|
writable: true,
|
|
429
485
|
});
|
|
430
486
|
}
|
|
487
|
+
export const COLLAPSED_ACTIVITY_ENTRIES = 6;
|
|
488
|
+
/** Live phase, waits, and chronological history; absence of activity is shown, not judged. */
|
|
489
|
+
export function renderActivity(activity, expanded, theme, now, part = "all") {
|
|
490
|
+
const ago = (at) => elapsed(Math.max(0, now - at));
|
|
491
|
+
if (part === "history")
|
|
492
|
+
return renderActivity(activity, expanded, theme, now).slice(renderActivity(activity, expanded, theme, now, "summary").length);
|
|
493
|
+
const lines = [theme.fg(activity.finishedAt === undefined ? "accent" : "muted", `activity: ${compact(formatActivityStatus(activity, now), 200)}`)];
|
|
494
|
+
const model = activity.model;
|
|
495
|
+
if (model && model.completedAt === undefined) {
|
|
496
|
+
lines.push(theme.fg("muted", model.firstStreamAt === undefined
|
|
497
|
+
? ` model request #${model.round} sent ${ago(model.startedAt)} ago · no stream events received yet`
|
|
498
|
+
: ` model request #${model.round} sent ${ago(model.startedAt)} ago · ${model.streamEvents} stream events, ${compactCount(model.receivedChars)} chars · last ${ago(model.lastStreamAt)} ago`));
|
|
499
|
+
if (model.tail)
|
|
500
|
+
lines.push(theme.fg("dim", ` receiving ${model.receiving === "tool_call" ? `${compact(model.toolName ?? "tool call", 40)} arguments` : model.receiving ?? "output"}: …${compact(model.tail, 160)}`));
|
|
501
|
+
}
|
|
502
|
+
for (const tool of activity.tools) {
|
|
503
|
+
lines.push(theme.fg("warning", ` tool ${compact(tool.name, 40)} running ${ago(tool.startedAt)} · ${tool.lastOutputAt === undefined ? "no output yet" : `last output ${ago(tool.lastOutputAt)} ago`}`));
|
|
504
|
+
lines.push(theme.fg("dim", ` args: ${compact(tool.arguments, 200)}`));
|
|
505
|
+
if (tool.outputTail)
|
|
506
|
+
lines.push(theme.fg("dim", ` output: …${compact(tool.outputTail, 200)}`));
|
|
507
|
+
}
|
|
508
|
+
for (const limitation of activity.limitations)
|
|
509
|
+
lines.push(theme.fg("muted", ` unavailable: ${compact(limitation, 200)}`));
|
|
510
|
+
if (part === "summary")
|
|
511
|
+
return lines;
|
|
512
|
+
const visible = expanded ? activity.entries : activity.entries.slice(-COLLAPSED_ACTIVITY_ENTRIES);
|
|
513
|
+
const hidden = activity.droppedEntries + activity.entries.length - visible.length;
|
|
514
|
+
lines.push(theme.fg("dim", `history · ${activity.sequence} events${hidden ? ` · ${hidden} earlier ${expanded ? "trimmed" : "hidden (expand for more)"}` : ""}:`));
|
|
515
|
+
for (const entry of visible) {
|
|
516
|
+
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : entry.kind === "human_input" ? "warning" : "muted";
|
|
517
|
+
lines.push(theme.fg(color, ` ${`+${elapsed(Math.max(0, entry.timestamp - activity.startedAt))}`.padStart(7)} ${compact(entry.summary, 200)}`));
|
|
518
|
+
// Show a detail preview only when the summary line does not already contain it.
|
|
519
|
+
if (expanded && entry.detail && entry.kind !== "workspace" && !compact(entry.summary, 400).includes(compact(entry.detail, 400))) {
|
|
520
|
+
lines.push(theme.fg("dim", ` ${compact(entry.detail, 300)}${entry.detailLength ? ` (${compactCount(entry.detailLength)} chars)` : ""}`));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return lines;
|
|
524
|
+
}
|
|
431
525
|
/** Focused status reads must show the requested execution, including historical ones. */
|
|
432
|
-
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now()) {
|
|
526
|
+
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now(), activity) {
|
|
433
527
|
const failed = node.status === "failed";
|
|
434
528
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
435
529
|
const timing = [
|
|
@@ -453,13 +547,54 @@ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress
|
|
|
453
547
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
454
548
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
|
|
455
549
|
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
550
|
+
// Activity describes one execution; never mix histories from repeated visits.
|
|
551
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
|
|
456
552
|
...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
|
|
457
553
|
...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
|
|
458
554
|
...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
|
|
459
555
|
];
|
|
460
556
|
return new Text(lines.join("\n"), 0, 0);
|
|
461
557
|
}
|
|
462
|
-
|
|
558
|
+
/** Status, timing, and live waits above a node's session transcript. */
|
|
559
|
+
export function renderSessionHeader(node, theme, now, progress, activity) {
|
|
560
|
+
const failed = node.status === "failed";
|
|
561
|
+
const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
|
|
562
|
+
const facts = [
|
|
563
|
+
...(node.executionId ? [compact(node.executionId, 80)] : []),
|
|
564
|
+
...(node.iteration ? [`iteration #${node.iteration}`] : []),
|
|
565
|
+
...(node.model ? [compact(node.model, 60)] : []),
|
|
566
|
+
...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
|
|
567
|
+
...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
|
|
568
|
+
];
|
|
569
|
+
return [
|
|
570
|
+
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : node.status === "running" ? "▶" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
571
|
+
...(facts.length ? [theme.fg("muted", facts.join(" · "))] : []),
|
|
572
|
+
...(live ? [theme.fg("muted", `context ${live.contextSource === "estimate" ? "~" : ""}${compactCount(live.contextTokens)}/${live.contextWindow === undefined ? "—" : compactCount(live.contextWindow)} · ${live.toolCalls} tool calls in ${live.toolRounds} rounds · ${live.phase} phase`)] : []),
|
|
573
|
+
...(node.humanInputs?.length ? [theme.fg("warning", humanInputNotice(node.humanInputs.length))] : []),
|
|
574
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, false, theme, now, "summary") : []),
|
|
575
|
+
...(node.error ? [theme.fg("error", `${node.error.code}: ${compact(node.error.message, 300)}`)] : []),
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
const humanInputNotice = (count) => `✎ received ${count} human message${count === 1 ? "" : "s"} while running; its result is not from the graph alone`;
|
|
579
|
+
/** What a node view shows below its pinned header when no live session is retained. */
|
|
580
|
+
export function renderNodeDetails(node, theme, now, activity) {
|
|
581
|
+
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
582
|
+
const workspace = node.workspace;
|
|
583
|
+
const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
|
|
584
|
+
const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
|
|
585
|
+
return [
|
|
586
|
+
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
587
|
+
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
588
|
+
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
589
|
+
...(node.humanInputs?.length ? [
|
|
590
|
+
theme.fg("warning", humanInputNotice(node.humanInputs.length)),
|
|
591
|
+
...node.humanInputs.map(input => theme.fg("warning", ` ✎ ${compact(clean(input.text), 300)}`)),
|
|
592
|
+
] : []),
|
|
593
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, true, theme, now, "history") : []),
|
|
594
|
+
...(node.output ? ["", ...clean(node.output).split("\n")] : []),
|
|
595
|
+
];
|
|
596
|
+
}
|
|
597
|
+
export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false, selectedNodeId) {
|
|
463
598
|
if (isError || !result?.nodes) {
|
|
464
599
|
const message = fallback ||
|
|
465
600
|
(isPartial ? "Braid is running…" : "Braid returned no execution details");
|
|
@@ -495,8 +630,17 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
495
630
|
if ("executions" in result)
|
|
496
631
|
lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
|
|
497
632
|
const chartStart = lines.length;
|
|
498
|
-
const chart = mermaidLines(result, theme);
|
|
633
|
+
const chart = mermaidLines(result, theme, selectedNodeId);
|
|
499
634
|
lines.push(...chart);
|
|
635
|
+
// Selection must stay usable when the chart cannot be drawn or does not fit.
|
|
636
|
+
const nodeList = selectedNodeId === undefined ? [] : [
|
|
637
|
+
theme.fg("dim", chart.length ? "nodes (selected node not found in the flowchart):" : "nodes (flowchart unavailable):"),
|
|
638
|
+
...Object.keys(graphParts(result).nodes).map(id => {
|
|
639
|
+
const node = result.nodes[id];
|
|
640
|
+
const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${node?.status ?? "pending"}`;
|
|
641
|
+
return id === selectedNodeId ? theme.bg("selectedBg", theme.bold(theme.fg("warning", text))) : theme.fg("muted", text);
|
|
642
|
+
}),
|
|
643
|
+
];
|
|
500
644
|
const workspaces = Object.values(result.workspaces ?? {}).filter(workspace => workspace.worktreeRoot);
|
|
501
645
|
if (workspaces.length) {
|
|
502
646
|
const active = workspaces.filter(workspace => ["preparing", "ready", "failed"].includes(workspace.state)).length;
|
|
@@ -512,5 +656,8 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
512
656
|
lines.push(...renderEventLog(result.events ?? [], true, theme));
|
|
513
657
|
if ("fullOutputPath" in result && result.fullOutputPath)
|
|
514
658
|
lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
|
|
515
|
-
return new FixedLines(lines, chartStart, chart.length
|
|
659
|
+
return new FixedLines(lines, chartStart, chart.length, nodeList, [
|
|
660
|
+
...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
|
|
661
|
+
{ mark: "▶ ACTIVE", label: "running node" },
|
|
662
|
+
]);
|
|
516
663
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages a human sent to running executions, keyed by execution ID. The
|
|
3
|
+
* runner takes them before each model request; once an execution is closed,
|
|
4
|
+
* further messages are rejected and anything still queued is returned as
|
|
5
|
+
* undelivered.
|
|
6
|
+
*/
|
|
7
|
+
export class PiHumanInputs {
|
|
8
|
+
onChange;
|
|
9
|
+
queues = new Map();
|
|
10
|
+
closed = new Set();
|
|
11
|
+
constructor(onChange = () => { }) {
|
|
12
|
+
this.onChange = onChange;
|
|
13
|
+
}
|
|
14
|
+
send(executionId, text) {
|
|
15
|
+
if (typeof text !== "string" || !text.trim())
|
|
16
|
+
throw new TypeError("Message must not be empty");
|
|
17
|
+
if (this.closed.has(executionId))
|
|
18
|
+
throw new Error("This execution has finished; the message was not sent");
|
|
19
|
+
const input = { text, submittedAt: Date.now() };
|
|
20
|
+
const queue = this.queues.get(executionId) ?? [];
|
|
21
|
+
queue.push(input);
|
|
22
|
+
this.queues.set(executionId, queue);
|
|
23
|
+
this.changed();
|
|
24
|
+
return { ...input };
|
|
25
|
+
}
|
|
26
|
+
pending(executionId) {
|
|
27
|
+
return this.queues.get(executionId) ?? [];
|
|
28
|
+
}
|
|
29
|
+
/** Remove and return queued messages, oldest first. */
|
|
30
|
+
take(executionId) {
|
|
31
|
+
const queue = this.queues.get(executionId);
|
|
32
|
+
if (!queue?.length)
|
|
33
|
+
return [];
|
|
34
|
+
this.queues.delete(executionId);
|
|
35
|
+
this.changed();
|
|
36
|
+
return queue;
|
|
37
|
+
}
|
|
38
|
+
/** Stop accepting messages and return those never delivered. */
|
|
39
|
+
close(executionId) {
|
|
40
|
+
this.closed.add(executionId);
|
|
41
|
+
const undelivered = this.queues.get(executionId) ?? [];
|
|
42
|
+
this.queues.delete(executionId);
|
|
43
|
+
if (undelivered.length)
|
|
44
|
+
this.changed();
|
|
45
|
+
return undelivered;
|
|
46
|
+
}
|
|
47
|
+
changed() {
|
|
48
|
+
try {
|
|
49
|
+
this.onChange();
|
|
50
|
+
}
|
|
51
|
+
catch { /* Observers cannot fail a worker. */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** How a delivered message reads to the worker, so it is never mistaken for graph input. */
|
|
55
|
+
export function formatHumanInput(text) {
|
|
56
|
+
return "Message from the human supervising this Braid execution, sent while you were running " +
|
|
57
|
+
"(it may be relayed by their Braid assistant). " +
|
|
58
|
+
"It is not part of the node prompt or predecessor outputs. Take it into account for your remaining work; " +
|
|
59
|
+
"it does not lift tool, budget, workspace, or decision constraints.\n\n" + text;
|
|
60
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -108,6 +108,8 @@ function isJobSnapshot(value) {
|
|
|
108
108
|
["running", "completed", "failed", "cancelled"].includes(job.status ?? "") &&
|
|
109
109
|
!!job.live && typeof job.live.nodes === "object" && job.live.nodes !== null;
|
|
110
110
|
}
|
|
111
|
+
/** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
|
|
112
|
+
export const STATUS_ACTIVITY_ENTRIES = 40;
|
|
111
113
|
function executionControl(job) {
|
|
112
114
|
return job.execution && {
|
|
113
115
|
status: job.execution.status,
|
|
@@ -202,11 +204,12 @@ export function createBraidTools(jobs) {
|
|
|
202
204
|
jobId: Type.Optional(text("Exact session handle (e.g. job-1) or UUID; omit all IDs to list jobs.")),
|
|
203
205
|
executionId: Type.Optional(text("Exact execution ID from braid_status or a reminder; requires jobId. Takes precedence over nodeId; if both are provided they must match.")),
|
|
204
206
|
nodeId: Type.Optional(text("Exact node ID; requires jobId. Retrieve this node's latest full output/error even while the job is running.")),
|
|
207
|
+
activityBefore: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: `Focused reads include activity: the current phase, model/tool waits, and the latest ${STATUS_ACTIVITY_ENTRIES} history entries. Pass an entry sequence to page earlier entries.` })),
|
|
205
208
|
}, { additionalProperties: false });
|
|
206
209
|
const statusTool = defineTool({
|
|
207
210
|
name: "braid_status",
|
|
208
211
|
label: "Braid status",
|
|
209
|
-
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
212
|
+
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. Focused reads include live activity: phase, time in phase and since the last observed signal, model request/stream state, running tools, and recent history. Lack of observed activity is a diagnostic signal, not proof of a stall. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
210
213
|
parameters: statusParameters,
|
|
211
214
|
renderResult(result, options, theme, ctx) {
|
|
212
215
|
const job = result.details;
|
|
@@ -227,7 +230,7 @@ export function createBraidTools(jobs) {
|
|
|
227
230
|
: nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
|
|
228
231
|
// Render at the read's observation time so a saved result never keeps counting.
|
|
229
232
|
if (selectedNode)
|
|
230
|
-
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt);
|
|
233
|
+
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt, job.selectedActivity);
|
|
231
234
|
if (executionId || nodeId)
|
|
232
235
|
return new Text(fallback || "Braid returned no node details", 0, 0);
|
|
233
236
|
if (job.error)
|
|
@@ -242,6 +245,8 @@ export function createBraidTools(jobs) {
|
|
|
242
245
|
async execute(_id, params) {
|
|
243
246
|
if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
|
|
244
247
|
throw new Error("nodeId/executionId requires jobId");
|
|
248
|
+
if (params.activityBefore !== undefined && params.nodeId === undefined && params.executionId === undefined)
|
|
249
|
+
throw new Error("activityBefore requires nodeId or executionId");
|
|
245
250
|
if (!params.jobId)
|
|
246
251
|
return {
|
|
247
252
|
content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
|
|
@@ -253,9 +258,18 @@ export function createBraidTools(jobs) {
|
|
|
253
258
|
if (params.nodeId !== undefined || params.executionId !== undefined) {
|
|
254
259
|
const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
|
|
255
260
|
const { id, executionId, status, error, output, ...nodeDetails } = node;
|
|
261
|
+
const activity = executionId ? jobs.getActivity(params.jobId, executionId, {
|
|
262
|
+
limit: STATUS_ACTIVITY_ENTRIES, ...(params.activityBefore === undefined ? {} : { before: params.activityBefore }),
|
|
263
|
+
}) : undefined;
|
|
264
|
+
// While running, activity is the useful part; afterwards the output is,
|
|
265
|
+
// so it comes first and history cannot push it out of the preview.
|
|
266
|
+
const running = activity && activity.finishedAt === undefined;
|
|
267
|
+
const live = activity ? { observedAt: job.live.observedAt, activity } : {};
|
|
256
268
|
const full = JSON.stringify({
|
|
257
269
|
jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
|
|
270
|
+
...(running ? live : {}),
|
|
258
271
|
node: { id, executionId, status, error, output, ...nodeDetails },
|
|
272
|
+
...(running ? {} : live),
|
|
259
273
|
}, null, 2);
|
|
260
274
|
const preview = truncateHead(full);
|
|
261
275
|
let suffix = "";
|
|
@@ -269,7 +283,7 @@ export function createBraidTools(jobs) {
|
|
|
269
283
|
}
|
|
270
284
|
// Focused reads do not claim the whole job's usage; final job retrieval does.
|
|
271
285
|
return { content: [{ type: "text", text: preview.content + suffix }], details: {
|
|
272
|
-
...job, selectedNode: node, ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
286
|
+
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
273
287
|
} };
|
|
274
288
|
}
|
|
275
289
|
const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
|
package/dist/jobs.js
CHANGED
|
@@ -3,14 +3,19 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
5
5
|
import { truncateHead, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { startBraid, validateGraph, } from "@chrok/braid";
|
|
6
|
+
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
7
7
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
8
8
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
9
|
+
import { PiTranscripts } from "./transcript.js";
|
|
10
|
+
import { PiHumanInputs } from "./human-input.js";
|
|
11
|
+
import { BraidAssistant } from "./assistant.js";
|
|
9
12
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
10
13
|
export class BraidJobs {
|
|
11
14
|
onFinished;
|
|
12
15
|
onNodeFinished;
|
|
13
16
|
jobs = new Map();
|
|
17
|
+
assistants = new Map();
|
|
18
|
+
cwd = process.cwd();
|
|
14
19
|
handles = new Map();
|
|
15
20
|
nextHandle = 1;
|
|
16
21
|
listeners = new Set();
|
|
@@ -46,6 +51,7 @@ export class BraidJobs {
|
|
|
46
51
|
: undefined;
|
|
47
52
|
const registry = ctx.modelRegistry;
|
|
48
53
|
const cwd = ctx.cwd;
|
|
54
|
+
this.cwd = cwd;
|
|
49
55
|
const job = {
|
|
50
56
|
handle: `job-${this.nextHandle++}`,
|
|
51
57
|
jobId: crypto.randomUUID(),
|
|
@@ -57,6 +63,9 @@ export class BraidJobs {
|
|
|
57
63
|
done: Promise.resolve(),
|
|
58
64
|
usageClaimed: false,
|
|
59
65
|
runningExecutions: new Map(),
|
|
66
|
+
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
67
|
+
transcripts: new PiTranscripts(() => this.changed()),
|
|
68
|
+
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
60
69
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
61
70
|
};
|
|
62
71
|
this.jobs.set(job.jobId, job);
|
|
@@ -79,6 +88,9 @@ export class BraidJobs {
|
|
|
79
88
|
runner: (() => {
|
|
80
89
|
const invoke = createPiRunner(registry, {
|
|
81
90
|
cwd,
|
|
91
|
+
activity: job.activity,
|
|
92
|
+
transcripts: job.transcripts,
|
|
93
|
+
humanInputs: job.humanInputs,
|
|
82
94
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
83
95
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
84
96
|
onUsage: (usage) => {
|
|
@@ -92,9 +104,23 @@ export class BraidJobs {
|
|
|
92
104
|
this.changed();
|
|
93
105
|
},
|
|
94
106
|
});
|
|
95
|
-
return async (request) => {
|
|
107
|
+
return async (request) => {
|
|
108
|
+
try {
|
|
109
|
+
await nextTurn();
|
|
110
|
+
return await invoke(request);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
job.activity.workerFinished(request.execution.executionId);
|
|
114
|
+
const executionId = request.execution.executionId;
|
|
115
|
+
if (executionId !== undefined) {
|
|
116
|
+
job.transcripts.human(executionId, job.humanInputs.close(executionId).map(input => input.text), false);
|
|
117
|
+
}
|
|
118
|
+
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
96
121
|
})(),
|
|
97
122
|
onEvent: (event) => {
|
|
123
|
+
job.activity.observe(event);
|
|
98
124
|
if (event.type === "workspace_updated") {
|
|
99
125
|
job.workspaces ??= {};
|
|
100
126
|
Object.defineProperty(job.workspaces, event.workspace.executionId ?? event.workspace.nodeId, {
|
|
@@ -198,7 +224,7 @@ export class BraidJobs {
|
|
|
198
224
|
const job = this.lookup(jobId);
|
|
199
225
|
if (!job)
|
|
200
226
|
return undefined;
|
|
201
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, ...snapshot } = job;
|
|
227
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
202
228
|
const copy = structuredClone(snapshot);
|
|
203
229
|
if (job.run)
|
|
204
230
|
copy.execution = job.run.snapshot();
|
|
@@ -220,6 +246,53 @@ export class BraidJobs {
|
|
|
220
246
|
throw new Error(`Unknown Braid node or execution in ${job.handle}. Use braid_status with only jobId to list executions.`);
|
|
221
247
|
return structuredClone(node);
|
|
222
248
|
}
|
|
249
|
+
/** Live activity for one exact execution; undefined until it is first observed. */
|
|
250
|
+
getActivity(jobId, executionId, page) {
|
|
251
|
+
const job = this.lookup(jobId);
|
|
252
|
+
if (!job)
|
|
253
|
+
throw this.unknownJob(jobId);
|
|
254
|
+
return job.activity.get(executionId, page);
|
|
255
|
+
}
|
|
256
|
+
/** The live conversation for one execution; read-only, and absent once evicted. */
|
|
257
|
+
getTranscript(jobId, executionId) {
|
|
258
|
+
const job = this.lookup(jobId);
|
|
259
|
+
if (!job)
|
|
260
|
+
throw this.unknownJob(jobId);
|
|
261
|
+
return job.transcripts.get(executionId);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Queue a human message for one running execution. The worker receives it
|
|
265
|
+
* before its next model request; running tools are not interrupted.
|
|
266
|
+
*/
|
|
267
|
+
sendInput(jobId, executionId, text) {
|
|
268
|
+
const job = this.lookup(jobId);
|
|
269
|
+
if (!job)
|
|
270
|
+
throw this.unknownJob(jobId);
|
|
271
|
+
if (!job.transcripts.isLive(executionId)) {
|
|
272
|
+
throw new Error("Messages can be sent only to a running execution whose live session has started");
|
|
273
|
+
}
|
|
274
|
+
return job.humanInputs.send(executionId, text);
|
|
275
|
+
}
|
|
276
|
+
/** Messages waiting for the execution's next model request. */
|
|
277
|
+
pendingInputs(jobId, executionId) {
|
|
278
|
+
const job = this.lookup(jobId);
|
|
279
|
+
if (!job)
|
|
280
|
+
throw this.unknownJob(jobId);
|
|
281
|
+
return structuredClone(job.humanInputs.pending(executionId));
|
|
282
|
+
}
|
|
283
|
+
/** The panel's side conversation about a job or one of its nodes, kept for the session. */
|
|
284
|
+
assistant(jobId, nodeId) {
|
|
285
|
+
const job = this.lookup(jobId);
|
|
286
|
+
if (!job)
|
|
287
|
+
throw this.unknownJob(jobId);
|
|
288
|
+
const key = JSON.stringify([job.jobId, nodeId ?? null]);
|
|
289
|
+
let assistant = this.assistants.get(key);
|
|
290
|
+
if (!assistant) {
|
|
291
|
+
assistant = new BraidAssistant(this, job.jobId, this.cwd, () => this.changed(), nodeId);
|
|
292
|
+
this.assistants.set(key, assistant);
|
|
293
|
+
}
|
|
294
|
+
return assistant;
|
|
295
|
+
}
|
|
223
296
|
update(jobId, patch) {
|
|
224
297
|
const job = this.lookup(jobId);
|
|
225
298
|
if (!job)
|
|
@@ -292,6 +365,8 @@ export class BraidJobs {
|
|
|
292
365
|
this.disposed = true;
|
|
293
366
|
for (const job of this.jobs.values())
|
|
294
367
|
job.controller.abort();
|
|
368
|
+
for (const assistant of this.assistants.values())
|
|
369
|
+
assistant.dispose();
|
|
295
370
|
this.listeners.clear();
|
|
296
371
|
}
|
|
297
372
|
}
|