@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/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
- constructor(lines, chartStart = lines.length, chartLength = 0) {
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
- if (chart.length === 0)
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
- const chartOutput = chartWidth <= width
20
- ? chart
21
- : [
22
- truncateToWidth(`[Flowchart needs ${chartWidth} columns; terminal width is ${width}. Expand your terminal to see it.]`, width, ""),
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
- function mermaidLabelLines(id, result) {
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
- const art = renderMermaid(mermaidSource(result));
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
- : "dim";
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
- export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false) {
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) => { await nextTurn(); return invoke(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
  }