@chrok/pi-braid 0.3.4 → 0.3.5

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 CHANGED
@@ -44,8 +44,10 @@ On completion, failure, or cancellation, the extension sends a custom
44
44
  Pi queues it as steering during streaming: it enters context after the current
45
45
  assistant response and its entire tool batch, before the next model step. It
46
46
  does not wait for the whole foreground task to finish or skip remaining tools.
47
- When idle, it starts a new agent turn automatically. The agent should wait for
48
- this reminder rather than poll.
47
+ When idle, it starts a new agent turn automatically. Tool descriptions, the
48
+ system prompt, and the submission receipt tell the agent to continue independent
49
+ work, or end its turn if it needs the result, and not to sleep, use a timer, or
50
+ poll in a loop to wait for the job.
49
51
  Stopping the foreground response does not stop background jobs.
50
52
 
51
53
  Jobs live in memory for the current Pi session. Quitting, reloading extensions,
@@ -107,9 +109,10 @@ All four types accept `model`, `notifyOnCompletion`, `pauseAfter`, and
107
109
  default to writable worktrees in Git; outside Git, all workers are read-only.
108
110
  The model-facing schema exposes these fields in one object with a `type` enum;
109
111
  core validation enforces the type-specific requirements before a submission or
110
- update takes effect. Submission/update replies echo the accepted node types,
111
- model overrides, policies, and edges without repeating prompts. If a definition
112
- problem repeats, inspect/report the mismatch instead of launching more probe jobs.
112
+ update takes effect. The submission reply gives the job ID and how to wait for
113
+ it; it does not repeat the graph the agent just sent. Update replies list the
114
+ resulting graph without prompts. If a definition problem repeats,
115
+ inspect/report the mismatch instead of launching more probe jobs.
113
116
 
114
117
  Edges use exact node IDs in `from` and `to`. `choice` is an exact label declared
115
118
  by the source decision; omitting it makes the edge unconditional. Pass `edges: []`
@@ -152,7 +155,7 @@ drops the queued message. Session shutdown suppresses delivery.
152
155
 
153
156
  Set `pauseAfter: true` to hold an execution's outgoing scheduling and receive a
154
157
  pause reminder. Independent branches continue. Fetch `braid_status` to obtain
155
- `execution.revision` and `execution.pausedExecutionIds`, then:
158
+ the current graph revision and paused execution IDs, then:
156
159
 
157
160
  ```json
158
161
  {
@@ -178,9 +181,9 @@ Pause reminders describe the event when it occurred: check current status and
178
181
  paused IDs before attempting an update/resume, since the job can time out or be
179
182
  cancelled before the parent handles the reminder.
180
183
 
181
- Focused `braid_status` reads also include the current control fields; the
182
- returned `node.revision` is the revision captured when that invocation started.
183
- Use `execution.revision` for updates even when inspecting an older invocation.
184
+ Focused `braid_status` reads also start with the current control fields: graph
185
+ status, revision, and paused execution IDs. Use that current revision for
186
+ updates even when inspecting an older invocation.
184
187
  After cancellation or finalization there are no resumable paused executions;
185
188
  the event log still records where pauses occurred.
186
189
 
@@ -317,9 +320,15 @@ counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
317
320
  tool also renders a flowchart; expand its result to see more log events.
318
321
  When selecting `nodeId` or `executionId`, it instead shows that invocation's
319
322
  output/error and activity, with the full text and history available on expansion.
320
- The JSON includes `activity` with the latest 40 history entries; pass
323
+
324
+ The Braid tools return plain-text documents to the model, not JSON. A
325
+ whole-job read lists the control state, every execution, their outputs,
326
+ workspaces, and the current graph without prompts. A focused read shows one
327
+ execution's output and its activity with the latest 40 history entries; pass
321
328
  `activityBefore` with an entry sequence to page earlier ones. While the invocation
322
- runs, `activity` comes before `node` so it survives preview truncation.
329
+ runs, the activity comes before the output so it survives preview truncation.
330
+ Worker `git` results read like Pi's bash output, and `decide` returns
331
+ `Recorded choice: <choice>`.
323
332
 
324
333
  `/braid` now opens this panel; it no longer arms the next prompt. To request
325
334
  Braid explicitly, ask the agent to analyze the task using Braid.
@@ -330,11 +339,9 @@ model lookup, credentials/OAuth, provider transport, filesystem tool execution,
330
339
  and token/cost accounting. The first
331
340
  whole-job `braid_status` retrieval of a finished job reports its accumulated Pi usage;
332
341
  subsequent retrievals do not count the same usage again.
333
- Use the status response's `usage` for Pi totals, including completed provider
334
- rounds from nodes that later fail or time out. Saved final result files expose
335
- the same accounting as `piUsage`; core `metadata.usage` only includes usage
336
- returned by runners. Large running status reads save a complete snapshot too,
337
- so truncation never requires waiting for job completion to inspect the graph.
342
+ The whole-job status text reports Pi's totals as `Usage`, including completed
343
+ provider rounds from nodes that later fail or time out; core `metadata.usage`
344
+ only includes usage returned by runners.
338
345
 
339
346
  ## When Pi will use Braid
340
347
 
@@ -378,7 +385,7 @@ only the current source IDs and diagnoses missing, duplicate or unexpected IDs.
378
385
 
379
386
  ## Install from npm
380
387
 
381
- Requires Node.js 22.19+ and Pi 1.0.1 (the tested version):
388
+ Requires Node.js 22.19+ and Pi 1.2.0 (the tested version):
382
389
 
383
390
  ```sh
384
391
  pi install npm:@chrok/pi-braid
@@ -389,7 +396,7 @@ The package depends on the exact matching `@chrok/braid` release; npm installs
389
396
  core automatically. It does not bundle core or depend on a source checkout.
390
397
  Pi supplies its core peer packages at runtime.
391
398
  Their wildcard ranges follow Pi's packaging convention, not universal version
392
- compatibility. Development and CI pin Pi 1.0.1.
399
+ compatibility. Development and CI pin Pi 1.2.0.
393
400
 
394
401
  ## Install this local checkout in Pi
395
402
 
@@ -618,9 +625,9 @@ writes already made. Core waits for tracked writes, preserves checkpoints, and
618
625
  removes worker worktrees before completing cancellation.
619
626
 
620
627
  The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
621
- the adapter writes a full JSON result to a temporary file (mode 600 on POSIX;
622
- inherited ACLs on Windows) and includes
623
- its path in the tool output.
628
+ the adapter writes the complete text to a temporary file (mode 600 on POSIX;
629
+ inherited ACLs on Windows) and ends the preview with its path, for running jobs
630
+ as well as finished ones.
624
631
 
625
632
  For a test of proactive selection, start a fresh Pi turn with a task such as:
626
633
 
package/dist/command.js CHANGED
@@ -201,9 +201,6 @@ export class BraidPanel {
201
201
  ...(current.result ?? current.live),
202
202
  progress: current.live.progress,
203
203
  ...(current.workspaces ? { workspaces: current.workspaces } : {}),
204
- ...(current.fullOutputPath
205
- ? { fullOutputPath: current.fullOutputPath }
206
- : {}),
207
204
  }, true, false, this.theme, "", false, this.choosing).render(width),
208
205
  ]
209
206
  : ["No Braid jobs in this session."];
package/dist/display.js CHANGED
@@ -551,7 +551,7 @@ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress
551
551
  ...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
552
552
  ...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
553
553
  ...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
554
- ...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
554
+ ...(fullOutputPath ? [theme.fg("dim", `full text: ${compact(fullOutputPath, 240)}`)] : []),
555
555
  ];
556
556
  return new Text(lines.join("\n"), 0, 0);
557
557
  }
@@ -655,7 +655,7 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
655
655
  if (expanded)
656
656
  lines.push(...renderEventLog(result.events ?? [], true, theme));
657
657
  if ("fullOutputPath" in result && result.fullOutputPath)
658
- lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
658
+ lines.push(theme.fg("dim", `full text: ${compact(result.fullOutputPath, 240)}`));
659
659
  return new FixedLines(lines, chartStart, chart.length, nodeList, [
660
660
  ...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
661
661
  { mark: "▶ ACTIVE", label: "running node" },
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { BraidJobs } from "./jobs.js";
8
8
  import { registerBraidCommand, registerBraidWidget } from "./command.js";
9
9
  import { registerReviewCommand } from "./review.js";
10
10
  import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
11
+ import { formatJobList, formatJobStatus, formatNodeStatus, formatReceipt, formatResume, formatUpdate } from "./status-text.js";
11
12
  const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
12
13
  const prompt = () => Type.Union([
13
14
  text("Instructions for this node; it does not receive the parent conversation."),
@@ -89,14 +90,24 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
89
90
  "merge combines selected predecessor checkpoints into a new isolated worktree; integrate writes selected changes to the invoking checkout while preserving user edits. Workers apply changes with Git/file tools, then call finish_merge using executionId for each source; finish_merge only records dispositions and does not apply changes. There is no automatic final integration. " +
90
91
  "Do not set workspace on merge/integrate nodes. Checkpoints remain recoverable after cleanup. Optional failed predecessors pass errors and partial work along unconditional edges; requireSuccess=true makes failure abort the job. " +
91
92
  "Nodes have local read/ls and Git inspection; writable nodes have write/edit and Pi shell tools for dependencies, builds, and tests, while merge/integrate also have local Git integration tools. Search tools require local rg/fd. Outside Git all filesystem access is read-only; read-only nodes have no shell. Worktrees are not an OS sandbox: prompts constrain shell writes and shared resources. Checkpoints omit ignored new files. Parent extension/MCP tools and recursive Braid calls are not provided. Nodes should verify their changes; the parent reviews results and performs any remaining validation after integration.";
93
+ /** Lifecycle guidance shared by the system prompt and tool descriptions. */
94
+ export const BRAID_ASYNC_GUIDANCE = "Braid is asynchronous: braid returns a jobId immediately and the job keeps running independently of the foreground response; ending your turn does not cancel it. " +
95
+ "Completion, failure, or cancellation sends a reminder: an idle agent is resumed automatically, and a running agent receives it at the next model step. " +
96
+ "After submitting, continue useful independent work. If nothing else can proceed until the result arrives, end your turn and let the reminder resume the task. " +
97
+ "Do not call sleep, run a shell sleep, use a timer, or enter a sleep/poll loop solely to wait for a Braid job; delays required by the user's actual task remain allowed. " +
98
+ "On the reminder, retrieve results with braid_status and continue the original task. Between reminders, use braid_status only for deliberate progress checks or diagnosis.";
99
+ /** Short reminder repeated in every submission receipt. */
100
+ export const BRAID_WAIT_REMINDER = "Braid runs asynchronously and notifies you when the job finishes. Continue independent work, or end your turn if you need the result before proceeding; the completion reminder will resume you. " +
101
+ "Do not sleep or poll in a loop to wait for this job. Retrieve its results with braid_status after the reminder.";
92
102
  const BRAID_USAGE_GUIDANCE = [
93
103
  "Braid is a proactive execution primitive, not only a user-requested command.",
94
104
  "Selection rule: for a code review, bug investigation, design comparison, test-planning request, or change spanning multiple files, call braid FIRST when two or more concerns can be handled independently. Nodes can analyze the project and implement changes in isolated Git worktrees. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.",
95
105
  BRAID_FILESYSTEM_GUIDANCE,
96
106
  "For repeated instructions, define promptTemplates once and use prompt={template: name, variables: {name: value}} on nodes. Values are strings inserted literally into {{name}} placeholders; plain-string prompts remain supported.",
97
- "When Braid fits, submit a graph: use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. The tool returns a jobId immediately. Continue independent work or finish your turn while it runs; do not poll repeatedly. A completion reminder will resume you. Use braid_status with the jobId to retrieve terminal outputs before relying on them.",
107
+ "When Braid fits, submit a graph: use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. Use braid_status with the jobId to retrieve terminal outputs before relying on them.",
108
+ BRAID_ASYNC_GUIDANCE,
98
109
  "Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes.",
99
- "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. Check the accepted node types and settings in the tool response. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies and loops: a node added without incoming edges can start immediately, even while another execution is paused.",
110
+ "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies and loops: a node added without incoming edges can start immediately, even while another execution is paused.",
100
111
  "Do not use braid for a simple one-step answer, a trivial direct edit, a single shell command, or when decomposition adds no value. Writable nodes can implement and test their work. The parent reviews results and performs any remaining validation after Braid completes.",
101
112
  ].join("\n");
102
113
  function isJobSnapshot(value) {
@@ -110,21 +121,15 @@ function isJobSnapshot(value) {
110
121
  }
111
122
  /** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
112
123
  export const STATUS_ACTIVITY_ENTRIES = 40;
113
- function executionControl(job) {
114
- return job.execution && {
115
- status: job.execution.status,
116
- revision: job.execution.revision,
117
- pausedExecutionIds: job.execution.pausedExecutionIds,
118
- };
119
- }
120
- function graphReceipt(job) {
121
- const graph = job.execution?.graph;
122
- return graph && {
123
- // Echo accepted types/policies without duplicating potentially large prompts.
124
- nodes: graph.nodes.map(({ prompt: _prompt, ...node }) => node),
125
- edges: graph.edges,
126
- ...(graph.loops ? { loops: graph.loops } : {}),
127
- };
124
+ /** Bound a text result; the complete document stays readable in a private file. */
125
+ async function boundedText(text, name) {
126
+ const preview = truncateHead(text);
127
+ if (!preview.truncated)
128
+ return { text };
129
+ const directory = await mkdtemp(join(tmpdir(), "braid-status-"));
130
+ const path = join(directory, name);
131
+ await writeFile(path, text, { mode: 0o600 });
132
+ return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
128
133
  }
129
134
  export function createBraidTools(jobs) {
130
135
  const braidTool = defineTool({
@@ -141,7 +146,8 @@ export function createBraidTools(jobs) {
141
146
  "no parent history, inherited extension/MCP tools, or recursive Braid tools. " +
142
147
  BRAID_FILESYSTEM_GUIDANCE + " " +
143
148
  "Do not use it for a simple one-step answer or trivial direct edit. " +
144
- "Use braid_status(jobId) for progress and results, or braid_cancel(jobId) to stop it. A completion reminder resumes the agent if idle; do independent work or end your turn instead of polling. Humans can open /braid for the live flow panel. " +
149
+ "Use braid_cancel(jobId) to stop it. Humans can open /braid for the live flow panel. " +
150
+ BRAID_ASYNC_GUIDANCE + " " +
145
151
  "Set notifyOnCompletion=true on selected nodes for intermediate success/failure reminders; retrieve their output/error with braid_status({jobId, nodeId}). Notifications do not pause scheduling; use pauseAfter for a hold, and braid_update to edit definitions. " +
146
152
  "Read result.status: failed graphs can still return successful terminal outputs.",
147
153
  promptSnippet: "Use FIRST for nontrivial code review/debug/design/implementation work; set workspace=read-only for analysis/synthesis, use worktrees for edits, merge to combine snapshots, and integrate to apply changes",
@@ -183,13 +189,7 @@ export function createBraidTools(jobs) {
183
189
  content: [
184
190
  {
185
191
  type: "text",
186
- text: JSON.stringify({
187
- jobId: job.handle,
188
- canonicalJobId: job.jobId,
189
- status: job.status,
190
- graph: graphReceipt(job),
191
- message: "Running in background. Check the accepted graph above. Use braid_status to retrieve progress/results. Reminders arrive after the current step or resume you when idle; do not poll repeatedly.",
192
- }),
192
+ text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`),
193
193
  },
194
194
  ],
195
195
  details: job,
@@ -209,7 +209,7 @@ export function createBraidTools(jobs) {
209
209
  const statusTool = defineTool({
210
210
  name: "braid_status",
211
211
  label: "Braid status",
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.",
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. Every job read starts with the current graph revision and paused execution IDs for live control. Results are text; long ones are truncated and the full text is saved to a file whose path is shown. Usage is Pi's provider accounting, including rounds from failed nodes. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically: read results after the reminder, or deliberately for a progress check or diagnosis; do not sleep, use a timer, or poll in a loop to wait for a job.",
213
213
  parameters: statusParameters,
214
214
  renderResult(result, options, theme, ctx) {
215
215
  const job = result.details;
@@ -249,7 +249,7 @@ export function createBraidTools(jobs) {
249
249
  throw new Error("activityBefore requires nodeId or executionId");
250
250
  if (!params.jobId)
251
251
  return {
252
- content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
252
+ content: [{ type: "text", text: formatJobList(jobs.list()) }],
253
253
  details: undefined,
254
254
  };
255
255
  const job = jobs.get(params.jobId);
@@ -257,63 +257,22 @@ export function createBraidTools(jobs) {
257
257
  throw jobs.unknownJob(params.jobId);
258
258
  if (params.nodeId !== undefined || params.executionId !== undefined) {
259
259
  const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
260
- const { id, executionId, status, error, output, ...nodeDetails } = node;
261
- const activity = executionId ? jobs.getActivity(params.jobId, executionId, {
260
+ const activity = node.executionId ? jobs.getActivity(params.jobId, node.executionId, {
262
261
  limit: STATUS_ACTIVITY_ENTRIES, ...(params.activityBefore === undefined ? {} : { before: params.activityBefore }),
263
262
  }) : 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 } : {};
268
- const full = JSON.stringify({
269
- jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
270
- ...(running ? live : {}),
271
- node: { id, executionId, status, error, output, ...nodeDetails },
272
- ...(running ? {} : live),
273
- }, null, 2);
274
- const preview = truncateHead(full);
275
- let suffix = "";
276
- let nodeOutputPath;
277
- if (preview.truncated) {
278
- const directory = await mkdtemp(join(tmpdir(), "braid-node-result-"));
279
- const path = join(directory, "result.json");
280
- await writeFile(path, full, { mode: 0o600 });
281
- nodeOutputPath = path;
282
- suffix = `\n[Preview truncated. Full node result: ${path}]`;
283
- }
263
+ const result = await boundedText(formatNodeStatus({ job, node, ...(activity ? { activity } : {}), now: Date.now() }), "node.txt");
284
264
  // Focused reads do not claim the whole job's usage; final job retrieval does.
285
- return { content: [{ type: "text", text: preview.content + suffix }], details: {
286
- ...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(nodeOutputPath ? { nodeOutputPath } : {}),
265
+ return { content: [{ type: "text", text: result.text }], details: {
266
+ ...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(result.path ? { nodeOutputPath: result.path } : {}),
287
267
  } };
288
268
  }
289
- const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
290
- // Put control/error/accounting fields before large prompts, outputs and logs.
291
- const full = JSON.stringify({
292
- jobId, handle, status,
293
- error: error ?? job.result?.error ?? execution?.error,
294
- usage: providerUsage, fullOutputPath,
295
- execution: execution && { ...executionControl(job), ...execution },
296
- ...snapshot,
297
- }, null, 2);
298
- const preview = truncateHead(full);
299
- let suffix = "";
300
- let details = job;
301
- if (preview.truncated) {
302
- if (job.fullOutputPath)
303
- suffix = `\n[Preview truncated. Full result/log: ${job.fullOutputPath}]`;
304
- else {
305
- const directory = await mkdtemp(join(tmpdir(), "braid-status-"));
306
- const path = join(directory, "status.json");
307
- await writeFile(path, full, { mode: 0o600 });
308
- details = { ...job, fullOutputPath: path };
309
- suffix = `\n[Preview truncated. Full status snapshot: ${path}]`;
310
- }
311
- }
269
+ const result = await boundedText(formatJobStatus(job, Date.now()), "status.txt");
270
+ const details = result.path ? { ...job, fullOutputPath: result.path } : job;
312
271
  // Saving a running snapshot can race job completion; claim usage only
313
272
  // when this response actually contains a terminal snapshot.
314
273
  const usage = job.status === "running" ? undefined : jobs.claimUsage(job.jobId);
315
274
  return {
316
- content: [{ type: "text", text: preview.content + suffix }],
275
+ content: [{ type: "text", text: result.text }],
317
276
  details,
318
277
  ...(usage ? { usage } : {}),
319
278
  };
@@ -342,14 +301,14 @@ export function createBraidTools(jobs) {
342
301
  });
343
302
  const updateParameters = Type.Object({
344
303
  jobId: text("Exact session handle (e.g. job-1) or UUID."),
345
- expectedRevision: Type.Integer({ minimum: 0, description: "Current execution.revision from braid_status. A stale revision rejects the entire update." }),
304
+ expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status. A stale revision rejects the entire update." }),
346
305
  upsertNodes: Type.Optional(Type.Array(nodeParameters, { description: "Complete node definitions to add or replace, not partial patches. Add dependencies and loops in this SAME update: new roots may start immediately even if another execution is paused. Already admitted executions keep their captured definitions." })),
347
306
  removeNodeIds: Type.Optional(Type.Array(text(), { description: "Node IDs to remove, together with all incident edges." })),
348
307
  addEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Edges to add. Pin a historical source using executionId when needed." })),
349
- removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from execution.graph.edges. Omitted optional fields do not act as wildcards." })),
308
+ removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from the current graph edges in braid_status. Omitted optional fields do not act as wildcards." })),
350
309
  promptTemplates: Type.Optional(Type.Record(Type.String(), text(), { description: `${templateDescription} Replaces the entire template map; omit to preserve it. Include every template still referenced by nodes.` })),
351
310
  loops: Type.Optional(Type.Array(loopParameters, { description: "Replaces all loop definitions; omit to preserve them, or use [] to remove all (also remove their feedback edges)." })),
352
- resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from execution.pausedExecutionIds, not node IDs. Releases them atomically with the graph edit." })),
311
+ resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })),
353
312
  }, { additionalProperties: false });
354
313
  const updateTool = defineTool({
355
314
  name: "braid_update", label: "Update Braid",
@@ -364,13 +323,13 @@ export function createBraidTools(jobs) {
364
323
  catch (error) {
365
324
  throw new Error(`Braid update rejected; no changes or resumes were applied. ${error instanceof Error ? error.message : String(error)}. Retry the complete corrected patch, including its edges/loops/resume; adding disconnected nodes separately can start them immediately.`);
366
325
  }
367
- return { content: [{ type: "text", text: JSON.stringify({ jobId: job.handle, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds, graph: graphReceipt(job) }) }], details: job };
326
+ return { content: [{ type: "text", text: formatUpdate(job) }], details: job };
368
327
  },
369
328
  });
370
329
  const resumeParameters = Type.Object({
371
330
  jobId: text("Exact session handle (e.g. job-1) or UUID."),
372
- expectedRevision: Type.Integer({ minimum: 0, description: "Current execution.revision from braid_status." }),
373
- executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "IDs from execution.pausedExecutionIds, not node IDs. Only these completed, paused executions are released." }),
331
+ expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status." }),
332
+ executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs. Only these completed, paused executions are released." }),
374
333
  }, { additionalProperties: false });
375
334
  const resumeTool = defineTool({
376
335
  name: "braid_resume", label: "Resume Braid",
@@ -378,7 +337,7 @@ export function createBraidTools(jobs) {
378
337
  parameters: resumeParameters,
379
338
  async execute(_id, params) {
380
339
  const job = jobs.resume(params.jobId, params.executionIds, params.expectedRevision);
381
- return { content: [{ type: "text", text: JSON.stringify({ jobId: job.handle, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds }) }], details: job };
340
+ return { content: [{ type: "text", text: formatResume(job) }], details: job };
382
341
  },
383
342
  });
384
343
  return { braidTool, statusTool, cancelTool, updateTool, resumeTool };
package/dist/jobs.js CHANGED
@@ -1,8 +1,4 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { setImmediate as nextTurn } from "node:timers/promises";
5
- import { truncateHead, } from "@earendil-works/pi-coding-agent";
6
2
  import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
7
3
  import { applyEvent, applyProgress, createLiveState, } from "./display.js";
8
4
  import { createPiRunner, sumPiUsage } from "./runner.js";
@@ -182,16 +178,6 @@ export class BraidJobs {
182
178
  const status = job.result.error?.code === "CANCELLED"
183
179
  ? "cancelled"
184
180
  : job.result.status;
185
- // Core metadata counts usage returned by runners; Pi also records completed
186
- // provider rounds from workers that later fail or time out.
187
- const full = JSON.stringify({ ...job.result, workspaces: job.workspaces, piUsage: job.usage }, null, 2);
188
- const preview = JSON.stringify({ ...this.get(job.jobId), status }, null, 2);
189
- if (truncateHead(preview).truncated) {
190
- const directory = await mkdtemp(join(tmpdir(), "braid-result-"));
191
- const path = join(directory, "result.json");
192
- await writeFile(path, full, { mode: 0o600 });
193
- job.fullOutputPath = path;
194
- }
195
181
  job.status = status;
196
182
  }
197
183
  catch (error) {
package/dist/runner.js CHANGED
@@ -5,6 +5,18 @@ import { createWorktreeWriteTools } from "./write-tools.js";
5
5
  import { createAvailableReadTools } from "./read-tools.js";
6
6
  import { createWorkspaceShellTools } from "./shell-tools.js";
7
7
  import { formatHumanInput } from "./human-input.js";
8
+ /**
9
+ * Present a Git result the way Pi's bash tool presents command output. Output
10
+ * stays byte-exact, so a diff can be passed back to git apply.
11
+ */
12
+ export function formatGitResult(result) {
13
+ let output = result.stdout;
14
+ if (result.stderr)
15
+ output += (output && !output.endsWith("\n") ? "\n" : "") + result.stderr;
16
+ if (result.exitCode === 0)
17
+ return output || "(no output)";
18
+ return `${output ? `${output.replace(/\n$/, "")}\n\n` : ""}Command exited with code ${result.exitCode}`;
19
+ }
8
20
  /** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
9
21
  export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
10
22
  const options = typeof onUsageOrOptions === "function"
@@ -289,7 +301,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
289
301
  if (call.name === "git" && request.git) {
290
302
  const args = parseGitToolArguments(call.arguments, (request.node.type === "merge" || request.node.type === "integrate"));
291
303
  const result = await request.git(args.args, args.input);
292
- return toolResult(call, JSON.stringify(result), result.exitCode !== 0);
304
+ return toolResult(call, formatGitResult(result), result.exitCode !== 0);
293
305
  }
294
306
  if (call.name === "finish_merge" && request.merge) {
295
307
  const dispositions = parseFinishMergeArguments(call.arguments, request.merge.sources.map(source => source.executionId));
@@ -339,7 +351,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
339
351
  // No coercion or prose parsing: the core enforces the enum and exactly one call.
340
352
  request.decide(args.choice);
341
353
  decided = true;
342
- return toolResult(call, JSON.stringify({ choice: args.choice }), false);
354
+ return toolResult(call, `Recorded choice: ${args.choice}`, false);
343
355
  };
344
356
  while (true) {
345
357
  const response = await complete();
@@ -0,0 +1,179 @@
1
+ /** Model-facing text documents for the parent Braid tools. */
2
+ const iso = (ms) => new Date(ms).toISOString();
3
+ const duration = (ms) => ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
4
+ const failure = (error) => `${error.code}: ${error.message}`;
5
+ const ids = (values) => values?.length ? values.join(", ") : "none";
6
+ const GOAL_CHARS = 2000;
7
+ const clip = (text, limit) => text.length > limit ? `${text.slice(0, limit)}… [${text.length} chars]` : text;
8
+ const indent = (text) => text.split("\n").map(line => line ? ` ${line}` : line).join("\n");
9
+ export function formatUsage(usage) {
10
+ if (!usage)
11
+ return undefined;
12
+ const parts = [`input ${usage.input}`, `output ${usage.output}`];
13
+ if (usage.cacheRead)
14
+ parts.push(`cache read ${usage.cacheRead}`);
15
+ if (usage.cacheWrite)
16
+ parts.push(`cache write ${usage.cacheWrite}`);
17
+ const cost = usage.cost?.total ? `, $${usage.cost.total.toFixed(4)}` : "";
18
+ return `${parts.join(", ")} (${usage.totalTokens} tokens${cost})`;
19
+ }
20
+ function nodeLine(node) {
21
+ const settings = [node.type];
22
+ if ("workspace" in node && node.workspace)
23
+ settings.push(`workspace=${node.workspace}`);
24
+ if ("choices" in node)
25
+ settings.push(`choices=${node.choices.join("|")}`);
26
+ if (node.model)
27
+ settings.push(`model=${node.model}`);
28
+ if (typeof node.prompt === "object")
29
+ settings.push(`template=${node.prompt.template}`);
30
+ for (const flag of ["pauseAfter", "notifyOnCompletion", "requireSuccess"])
31
+ if (node[flag])
32
+ settings.push(flag);
33
+ return `- ${node.id}: ${settings.join(", ")}`;
34
+ }
35
+ /** Accepted definitions without prompts, which the caller already sent. */
36
+ export function formatGraph(graph) {
37
+ const lines = ["Nodes:", ...graph.nodes.map(nodeLine), "Edges:"];
38
+ lines.push(...graph.edges.length ? graph.edges.map(edge => {
39
+ const labels = [edge.choice && `choice=${edge.choice}`, edge.feedback && `feedback=${edge.feedback}`, edge.executionId && `execution=${edge.executionId}`].filter(Boolean);
40
+ return `- ${edge.from} -> ${edge.to}${labels.length ? ` [${labels.join(", ")}]` : ""}`;
41
+ }) : ["- none"]);
42
+ if (graph.loops?.length)
43
+ lines.push("Loops:", ...graph.loops.map(loop => `- ${loop.id}: entry ${loop.entry}, at most ${loop.maxIterations} iterations`));
44
+ return lines.join("\n");
45
+ }
46
+ function workspaceLine(workspace) {
47
+ const parts = [`${workspace.mode} ${workspace.workingDirectory}`, workspace.state];
48
+ for (const root of [workspace.worktreeRoot, workspace.sourceRoot])
49
+ if (root && root !== workspace.workingDirectory)
50
+ parts.push(`root ${root}`);
51
+ if (workspace.checkpointRef)
52
+ parts.push(`checkpoint ${workspace.checkpointRef}`);
53
+ if (workspace.backupRef)
54
+ parts.push(`backup ${workspace.backupRef}`);
55
+ if (workspace.reason)
56
+ parts.push(workspace.reason);
57
+ for (const item of workspace.dispositions ?? [])
58
+ parts.push(`${item.executionId} ${item.disposition}: ${item.reason}`);
59
+ return parts.join(" · ");
60
+ }
61
+ function executionLine(execution) {
62
+ const parts = [execution.executionId ?? "(no execution)", `node ${execution.id}`];
63
+ if (execution.iteration)
64
+ parts.push(`iteration ${execution.iteration}`);
65
+ parts.push(execution.skipReason ? `${execution.status} (${execution.skipReason})` : execution.status);
66
+ if (execution.latencyMs !== undefined)
67
+ parts.push(duration(execution.latencyMs));
68
+ if (execution.decision)
69
+ parts.push(`decision ${execution.decision}`);
70
+ if (execution.error)
71
+ parts.push(failure(execution.error));
72
+ return `- ${parts.join(" · ")}`;
73
+ }
74
+ export function formatJobList(jobs) {
75
+ if (!jobs.length)
76
+ return "No Braid jobs in this session.";
77
+ return [`${jobs.length} Braid job${jobs.length === 1 ? "" : "s"} in this session, newest first:`,
78
+ ...jobs.map(job => `- ${job.handle} · ${job.status} · started ${iso(job.createdAt)} · id ${job.jobId}\n Goal: ${job.goal}`)].join("\n");
79
+ }
80
+ function control(execution) {
81
+ return execution ? `Graph ${execution.status} · revision ${execution.revision} · paused executions: ${ids(execution.pausedExecutionIds)}` : "Graph not started";
82
+ }
83
+ /** The submission receipt. The caller already has the graph it submitted. */
84
+ export function formatReceipt(job, message) {
85
+ return [`Braid job ${job.handle} started (${job.status}).`, `ID: ${job.jobId}`, message].join("\n");
86
+ }
87
+ export function formatUpdate(job) {
88
+ return [`Braid job ${job.handle} updated to revision ${job.execution.revision}.`,
89
+ `Paused executions: ${ids(job.execution.pausedExecutionIds)}`, "Current graph:", formatGraph(job.execution.graph)].join("\n");
90
+ }
91
+ export function formatResume(job) {
92
+ return `Braid job ${job.handle} resumed at revision ${job.execution.revision}. Still paused: ${ids(job.execution.pausedExecutionIds)}.`;
93
+ }
94
+ export function formatActivity(activity, now, jobId) {
95
+ const lines = [`Activity: ${activity.phase} for ${duration(now - activity.phaseStartedAt)}; last observed signal ${duration(now - activity.lastActivityAt)} ago (${activity.lastActivity}). Model requests ${activity.modelRequests}, tool calls ${activity.toolCalls}.`];
96
+ const model = activity.model;
97
+ if (model && model.completedAt === undefined) {
98
+ const stream = model.firstStreamAt === undefined ? "no stream received yet"
99
+ : `receiving ${model.receiving ?? "output"}${model.toolName ? ` for ${model.toolName}` : ""}, ${model.receivedChars} chars, last chunk ${duration(now - (model.lastStreamAt ?? model.firstStreamAt))} ago`;
100
+ lines.push(`Model request ${model.round}: started ${duration(now - model.startedAt)} ago; ${stream}.`);
101
+ }
102
+ for (const tool of activity.tools) {
103
+ lines.push(`Running tool ${tool.name} for ${duration(now - tool.startedAt)}: ${tool.arguments}`);
104
+ if (tool.outputTail)
105
+ lines.push(indent(`Output tail:\n${tool.outputTail}`));
106
+ }
107
+ for (const limitation of activity.limitations)
108
+ lines.push(`Not observable: ${limitation}`);
109
+ if (activity.entries.length) {
110
+ lines.push("History:");
111
+ for (const entry of activity.entries) {
112
+ lines.push(`- #${entry.sequence} ${iso(entry.timestamp)} ${entry.kind}${entry.toolName ? ` ${entry.toolName}` : ""}${entry.isError ? " (error)" : ""}${entry.durationMs !== undefined ? ` ${duration(entry.durationMs)}` : ""}: ${entry.summary}`);
113
+ if (entry.detail)
114
+ lines.push(indent(entry.detail + (entry.detailLength ? `\n[preview of ${entry.detailLength} chars]` : "")));
115
+ }
116
+ const first = activity.entries[0].sequence;
117
+ if (first > 1)
118
+ lines.push(first - 1 > activity.droppedEntries
119
+ ? `Earlier entries: braid_status({"jobId":"${jobId}","executionId":"${activity.executionId}","activityBefore":${first}})`
120
+ : `${activity.droppedEntries} earlier entries are no longer retained.`);
121
+ }
122
+ return lines.join("\n");
123
+ }
124
+ /** A focused read: the current control state, then one execution in full. */
125
+ export function formatNodeStatus({ job, node, activity, now }) {
126
+ const lines = [`Braid job ${job.handle} · ${job.status} · ${control(job.execution)}`,
127
+ `Execution ${node.executionId ?? "(none yet)"} · node ${node.id}${node.node ? ` (${node.node.type})` : ""}${node.iteration ? ` · iteration ${node.iteration}` : ""} · ${node.skipReason ? `${node.status} (${node.skipReason})` : node.status}`];
128
+ const timing = [node.startedAt !== undefined && `started ${iso(node.startedAt)}`, node.latencyMs !== undefined && `took ${duration(node.latencyMs)}`,
129
+ node.model && `model ${node.model}`, node.usage && `usage input ${node.usage.inputTokens}, output ${node.usage.outputTokens}`].filter(Boolean);
130
+ if (timing.length)
131
+ lines.push(timing.join(" · "));
132
+ if (node.decision)
133
+ lines.push(`Decision: ${node.decision}`);
134
+ if (node.error)
135
+ lines.push(`Error: ${failure(node.error)}`);
136
+ if (node.workspace)
137
+ lines.push(`Workspace: ${workspaceLine(node.workspace)}`);
138
+ if (node.humanInputs?.length)
139
+ lines.push("Human input delivered during this execution:", ...node.humanInputs.map(input => `- ${iso(input.timestamp)}: ${input.text}`));
140
+ const output = `Output:\n${node.output ?? "(none)"}`;
141
+ // While running, activity is the useful part; afterwards the output is.
142
+ const running = activity && activity.finishedAt === undefined;
143
+ const sections = [lines.join("\n"), ...running ? [formatActivity(activity, now, job.handle), output] : [output, ...activity ? [formatActivity(activity, now, job.handle)] : []]];
144
+ return sections.join("\n\n");
145
+ }
146
+ /** The whole job: control state, every execution, then their outputs and the current definition. */
147
+ export function formatJobStatus(job, now) {
148
+ const state = job.result ?? job.execution;
149
+ // Control fields lead: a long goal or output must not push them out of a truncated preview.
150
+ const header = [`Braid job ${job.handle} · ${job.status}`, `ID: ${job.jobId}`, control(job.execution)];
151
+ const error = job.error ?? (job.result?.error ? failure(job.result.error) : job.execution?.error ? failure(job.execution.error) : undefined);
152
+ if (error)
153
+ header.push(`Error: ${error}`);
154
+ const usage = formatUsage(job.usage);
155
+ if (usage)
156
+ header.push(`Usage: ${usage}`);
157
+ if (job.result)
158
+ header.push(`Finished ${iso(job.result.metadata.finishedAt)} after ${duration(job.result.metadata.latencyMs)}`);
159
+ else
160
+ header.push(`Started ${iso(job.createdAt)}, ${duration(now - job.createdAt)} ago`);
161
+ header.push(`Goal: ${clip(job.goal, GOAL_CHARS)}`);
162
+ const sections = [header.join("\n")];
163
+ const executions = Object.values(state?.executions ?? {});
164
+ if (executions.length)
165
+ sections.push([`Executions (${executions.length}):`, ...executions.map(executionLine)].join("\n"));
166
+ const pending = Object.values(state?.nodes ?? {}).filter(node => !node.executionId);
167
+ if (pending.length)
168
+ sections.push(["Nodes without executions:", ...pending.map(executionLine)].join("\n"));
169
+ const terminal = new Set(job.result?.terminalExecutionIds ?? []);
170
+ const outputs = executions.filter(execution => execution.output !== undefined);
171
+ if (outputs.length)
172
+ sections.push(["Outputs:", ...outputs.map(execution => `## ${execution.id} · ${execution.executionId}${terminal.has(execution.executionId) ? " · terminal" : ""}${execution.decision ? ` · decision ${execution.decision}` : ""}\n${execution.output}`)].join("\n\n"));
173
+ const workspaces = executions.filter(execution => execution.workspace);
174
+ if (workspaces.length)
175
+ sections.push(["Workspaces:", ...workspaces.map(execution => `- ${execution.executionId}: ${workspaceLine(execution.workspace)}`)].join("\n"));
176
+ if (job.execution)
177
+ sections.push(`Current graph (revision ${job.execution.revision}):\n${formatGraph(job.execution.graph)}`);
178
+ return sections.join("\n\n");
179
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "license": "MIT",
5
5
  "description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
6
6
  "type": "module",
7
7
  "dependencies": {
8
8
  "grok-mermaid": "^0.2.2",
9
- "@chrok/braid": "0.3.4"
9
+ "@chrok/braid": "0.3.5"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",
@@ -14,9 +14,9 @@
14
14
  "@earendil-works/pi-tui": "*"
15
15
  },
16
16
  "devDependencies": {
17
- "@earendil-works/pi-ai": "1.0.1",
18
- "@earendil-works/pi-tui": "1.0.1",
19
- "@earendil-works/pi-coding-agent": "1.0.1",
17
+ "@earendil-works/pi-ai": "1.2.0",
18
+ "@earendil-works/pi-tui": "1.2.0",
19
+ "@earendil-works/pi-coding-agent": "1.2.0",
20
20
  "typescript": "^5.0.0",
21
21
  "tsx": "^4.0.0",
22
22
  "@types/node": "^22.0.0"