@chrok/pi-braid 0.3.3 → 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 +79 -25
- package/dist/assistant.js +345 -0
- package/dist/command.js +259 -34
- package/dist/display.js +19 -4
- package/dist/human-input.js +60 -0
- package/dist/index.js +41 -82
- package/dist/jobs.js +47 -15
- package/dist/runner.js +33 -4
- package/dist/session-view.js +27 -1
- package/dist/shell-tools.js +12 -5
- package/dist/status-text.js +179 -0
- package/dist/transcript.js +14 -0
- package/package.json +5 -5
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.
|
|
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.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
286
|
-
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(
|
|
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
|
|
290
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
373
|
-
executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "IDs from
|
|
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:
|
|
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,17 +1,17 @@
|
|
|
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";
|
|
9
5
|
import { PiTranscripts } from "./transcript.js";
|
|
6
|
+
import { PiHumanInputs } from "./human-input.js";
|
|
7
|
+
import { BraidAssistant } from "./assistant.js";
|
|
10
8
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
11
9
|
export class BraidJobs {
|
|
12
10
|
onFinished;
|
|
13
11
|
onNodeFinished;
|
|
14
12
|
jobs = new Map();
|
|
13
|
+
assistants = new Map();
|
|
14
|
+
cwd = process.cwd();
|
|
15
15
|
handles = new Map();
|
|
16
16
|
nextHandle = 1;
|
|
17
17
|
listeners = new Set();
|
|
@@ -47,6 +47,7 @@ export class BraidJobs {
|
|
|
47
47
|
: undefined;
|
|
48
48
|
const registry = ctx.modelRegistry;
|
|
49
49
|
const cwd = ctx.cwd;
|
|
50
|
+
this.cwd = cwd;
|
|
50
51
|
const job = {
|
|
51
52
|
handle: `job-${this.nextHandle++}`,
|
|
52
53
|
jobId: crypto.randomUUID(),
|
|
@@ -60,6 +61,7 @@ export class BraidJobs {
|
|
|
60
61
|
runningExecutions: new Map(),
|
|
61
62
|
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
62
63
|
transcripts: new PiTranscripts(() => this.changed()),
|
|
64
|
+
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
63
65
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
64
66
|
};
|
|
65
67
|
this.jobs.set(job.jobId, job);
|
|
@@ -84,6 +86,7 @@ export class BraidJobs {
|
|
|
84
86
|
cwd,
|
|
85
87
|
activity: job.activity,
|
|
86
88
|
transcripts: job.transcripts,
|
|
89
|
+
humanInputs: job.humanInputs,
|
|
87
90
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
88
91
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
89
92
|
onUsage: (usage) => {
|
|
@@ -104,6 +107,10 @@ export class BraidJobs {
|
|
|
104
107
|
}
|
|
105
108
|
finally {
|
|
106
109
|
job.activity.workerFinished(request.execution.executionId);
|
|
110
|
+
const executionId = request.execution.executionId;
|
|
111
|
+
if (executionId !== undefined) {
|
|
112
|
+
job.transcripts.human(executionId, job.humanInputs.close(executionId).map(input => input.text), false);
|
|
113
|
+
}
|
|
107
114
|
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
108
115
|
}
|
|
109
116
|
};
|
|
@@ -171,16 +178,6 @@ export class BraidJobs {
|
|
|
171
178
|
const status = job.result.error?.code === "CANCELLED"
|
|
172
179
|
? "cancelled"
|
|
173
180
|
: job.result.status;
|
|
174
|
-
// Core metadata counts usage returned by runners; Pi also records completed
|
|
175
|
-
// provider rounds from workers that later fail or time out.
|
|
176
|
-
const full = JSON.stringify({ ...job.result, workspaces: job.workspaces, piUsage: job.usage }, null, 2);
|
|
177
|
-
const preview = JSON.stringify({ ...this.get(job.jobId), status }, null, 2);
|
|
178
|
-
if (truncateHead(preview).truncated) {
|
|
179
|
-
const directory = await mkdtemp(join(tmpdir(), "braid-result-"));
|
|
180
|
-
const path = join(directory, "result.json");
|
|
181
|
-
await writeFile(path, full, { mode: 0o600 });
|
|
182
|
-
job.fullOutputPath = path;
|
|
183
|
-
}
|
|
184
181
|
job.status = status;
|
|
185
182
|
}
|
|
186
183
|
catch (error) {
|
|
@@ -213,7 +210,7 @@ export class BraidJobs {
|
|
|
213
210
|
const job = this.lookup(jobId);
|
|
214
211
|
if (!job)
|
|
215
212
|
return undefined;
|
|
216
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, ...snapshot } = job;
|
|
213
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
217
214
|
const copy = structuredClone(snapshot);
|
|
218
215
|
if (job.run)
|
|
219
216
|
copy.execution = job.run.snapshot();
|
|
@@ -249,6 +246,39 @@ export class BraidJobs {
|
|
|
249
246
|
throw this.unknownJob(jobId);
|
|
250
247
|
return job.transcripts.get(executionId);
|
|
251
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Queue a human message for one running execution. The worker receives it
|
|
251
|
+
* before its next model request; running tools are not interrupted.
|
|
252
|
+
*/
|
|
253
|
+
sendInput(jobId, executionId, text) {
|
|
254
|
+
const job = this.lookup(jobId);
|
|
255
|
+
if (!job)
|
|
256
|
+
throw this.unknownJob(jobId);
|
|
257
|
+
if (!job.transcripts.isLive(executionId)) {
|
|
258
|
+
throw new Error("Messages can be sent only to a running execution whose live session has started");
|
|
259
|
+
}
|
|
260
|
+
return job.humanInputs.send(executionId, text);
|
|
261
|
+
}
|
|
262
|
+
/** Messages waiting for the execution's next model request. */
|
|
263
|
+
pendingInputs(jobId, executionId) {
|
|
264
|
+
const job = this.lookup(jobId);
|
|
265
|
+
if (!job)
|
|
266
|
+
throw this.unknownJob(jobId);
|
|
267
|
+
return structuredClone(job.humanInputs.pending(executionId));
|
|
268
|
+
}
|
|
269
|
+
/** The panel's side conversation about a job or one of its nodes, kept for the session. */
|
|
270
|
+
assistant(jobId, nodeId) {
|
|
271
|
+
const job = this.lookup(jobId);
|
|
272
|
+
if (!job)
|
|
273
|
+
throw this.unknownJob(jobId);
|
|
274
|
+
const key = JSON.stringify([job.jobId, nodeId ?? null]);
|
|
275
|
+
let assistant = this.assistants.get(key);
|
|
276
|
+
if (!assistant) {
|
|
277
|
+
assistant = new BraidAssistant(this, job.jobId, this.cwd, () => this.changed(), nodeId);
|
|
278
|
+
this.assistants.set(key, assistant);
|
|
279
|
+
}
|
|
280
|
+
return assistant;
|
|
281
|
+
}
|
|
252
282
|
update(jobId, patch) {
|
|
253
283
|
const job = this.lookup(jobId);
|
|
254
284
|
if (!job)
|
|
@@ -321,6 +351,8 @@ export class BraidJobs {
|
|
|
321
351
|
this.disposed = true;
|
|
322
352
|
for (const job of this.jobs.values())
|
|
323
353
|
job.controller.abort();
|
|
354
|
+
for (const assistant of this.assistants.values())
|
|
355
|
+
assistant.dispose();
|
|
324
356
|
this.listeners.clear();
|
|
325
357
|
}
|
|
326
358
|
}
|
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,19 @@ import { formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, mer
|
|
|
4
4
|
import { createWorktreeWriteTools } from "./write-tools.js";
|
|
5
5
|
import { createAvailableReadTools } from "./read-tools.js";
|
|
6
6
|
import { createWorkspaceShellTools } from "./shell-tools.js";
|
|
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
|
+
}
|
|
7
20
|
/** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
|
|
8
21
|
export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
|
|
9
22
|
const options = typeof onUsageOrOptions === "function"
|
|
@@ -150,6 +163,16 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
150
163
|
predecessors: request.predecessors.length,
|
|
151
164
|
workspace: `${workspace.mode} · ${workingDirectory}`,
|
|
152
165
|
}, toolByName);
|
|
166
|
+
const humanInputs = executionId === undefined ? undefined : options.humanInputs;
|
|
167
|
+
// Between model requests only: a running tool batch is never interrupted.
|
|
168
|
+
const deliverHumanInputs = () => {
|
|
169
|
+
const inputs = humanInputs?.take(executionId) ?? [];
|
|
170
|
+
for (const input of inputs) {
|
|
171
|
+
request.recordHumanInput?.(input.text);
|
|
172
|
+
context.messages.push({ role: "user", content: formatHumanInput(input.text), timestamp: Date.now() });
|
|
173
|
+
}
|
|
174
|
+
transcripts?.human(executionId, inputs.map(input => input.text), true);
|
|
175
|
+
};
|
|
153
176
|
const systemPrompt = context.systemPrompt;
|
|
154
177
|
const sessionId = crypto.randomUUID();
|
|
155
178
|
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -161,6 +184,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
161
184
|
let reportedContextTokens;
|
|
162
185
|
const complete = async () => {
|
|
163
186
|
request.signal.throwIfAborted();
|
|
187
|
+
deliverHumanInputs();
|
|
164
188
|
const budgets = [];
|
|
165
189
|
if (Number.isFinite(maxToolRounds)) {
|
|
166
190
|
budgets.push(`Tool round budget: ${toolRounds}/${maxToolRounds} used; ${maxToolRounds - toolRounds} remaining. A round is one assistant response containing tool calls.`);
|
|
@@ -277,7 +301,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
277
301
|
if (call.name === "git" && request.git) {
|
|
278
302
|
const args = parseGitToolArguments(call.arguments, (request.node.type === "merge" || request.node.type === "integrate"));
|
|
279
303
|
const result = await request.git(args.args, args.input);
|
|
280
|
-
return toolResult(call,
|
|
304
|
+
return toolResult(call, formatGitResult(result), result.exitCode !== 0);
|
|
281
305
|
}
|
|
282
306
|
if (call.name === "finish_merge" && request.merge) {
|
|
283
307
|
const dispositions = parseFinishMergeArguments(call.arguments, request.merge.sources.map(source => source.executionId));
|
|
@@ -327,7 +351,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
327
351
|
// No coercion or prose parsing: the core enforces the enum and exactly one call.
|
|
328
352
|
request.decide(args.choice);
|
|
329
353
|
decided = true;
|
|
330
|
-
return toolResult(call,
|
|
354
|
+
return toolResult(call, `Recorded choice: ${args.choice}`, false);
|
|
331
355
|
};
|
|
332
356
|
while (true) {
|
|
333
357
|
const response = await complete();
|
|
@@ -336,8 +360,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
336
360
|
.map((block) => block.text));
|
|
337
361
|
const calls = response.content.filter((block) => block.type === "toolCall");
|
|
338
362
|
if (calls.length === 0) {
|
|
339
|
-
if (response.stopReason === "stop")
|
|
340
|
-
|
|
363
|
+
if (response.stopReason === "stop") {
|
|
364
|
+
// A message that arrived during the final request gets one more round.
|
|
365
|
+
if (!humanInputs?.pending(executionId).length)
|
|
366
|
+
break;
|
|
367
|
+
context.messages.push(response);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
341
370
|
throw new Error("Pi model requested tool use without a tool call");
|
|
342
371
|
}
|
|
343
372
|
if (response.stopReason !== "toolUse") {
|
package/dist/session-view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AssistantMessageComponent, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Container, Spacer, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
4
4
|
/**
|
|
5
5
|
* Renders a worker conversation with Pi's own chat components, applying only
|
|
@@ -40,6 +40,21 @@ export class NodeSessionView {
|
|
|
40
40
|
this.version = transcript.version;
|
|
41
41
|
for (; this.rendered < transcript.messages.length; this.rendered++) {
|
|
42
42
|
const message = transcript.messages[this.rendered];
|
|
43
|
+
if (message.role === "human") {
|
|
44
|
+
this.gap();
|
|
45
|
+
this.add(new Text(message.delivered
|
|
46
|
+
? this.theme.fg("warning", "✎ Human input · delivered before the next model request")
|
|
47
|
+
: this.theme.fg("error", "✎ Human input · not delivered: the execution ended before its next model request"), 1, 0));
|
|
48
|
+
this.add(new UserMessageComponent(message.text, getMarkdownTheme()));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (message.role === "user") {
|
|
52
|
+
const text = typeof message.content === "string" ? message.content
|
|
53
|
+
: message.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
54
|
+
this.gap();
|
|
55
|
+
this.add(new UserMessageComponent(text, getMarkdownTheme()));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
43
58
|
if (message.role === "toolResult") {
|
|
44
59
|
this.tools.get(message.toolCallId)?.updateResult(message);
|
|
45
60
|
this.partials.delete(message.toolCallId);
|
|
@@ -50,6 +65,9 @@ export class NodeSessionView {
|
|
|
50
65
|
this.streaming = undefined;
|
|
51
66
|
component.updateContent(message, false);
|
|
52
67
|
this.toolCalls(message, transcript, true);
|
|
68
|
+
// A reply that requests no tools ends the turn; leave room below it right away.
|
|
69
|
+
if (message.stopReason !== "toolUse")
|
|
70
|
+
this.gap();
|
|
53
71
|
}
|
|
54
72
|
if (transcript.streaming) {
|
|
55
73
|
this.streaming ??= this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
|
|
@@ -99,12 +117,20 @@ export class NodeSessionView {
|
|
|
99
117
|
this.rendered = 0;
|
|
100
118
|
this.version = -1;
|
|
101
119
|
const intro = transcript.intro;
|
|
120
|
+
if (!intro)
|
|
121
|
+
return;
|
|
102
122
|
this.add(new UserMessageComponent(intro.prompt, getMarkdownTheme()));
|
|
103
123
|
this.add(new Text(this.theme.fg("dim", [
|
|
104
124
|
`goal: ${plain(intro.goal)}`,
|
|
105
125
|
`context: ${intro.predecessors} predecessor output${intro.predecessors === 1 ? "" : "s"} · workspace ${plain(intro.workspace)}`,
|
|
106
126
|
].join("\n")), 1, 0));
|
|
107
127
|
}
|
|
128
|
+
/** One blank line between turns: after a finished reply, or before a new message. */
|
|
129
|
+
gap() {
|
|
130
|
+
const last = this.chat.children.at(-1);
|
|
131
|
+
if (last && !(last instanceof Spacer))
|
|
132
|
+
this.add(new Spacer(1));
|
|
133
|
+
}
|
|
108
134
|
add(component) {
|
|
109
135
|
this.chat.addChild(component);
|
|
110
136
|
return component;
|
package/dist/shell-tools.js
CHANGED
|
@@ -32,15 +32,22 @@ function managedOperations(resolveShell, prefix = "") {
|
|
|
32
32
|
if (!child.pid)
|
|
33
33
|
return;
|
|
34
34
|
if (process.platform === "win32") {
|
|
35
|
-
await new Promise(
|
|
35
|
+
const failure = await new Promise(resolve => {
|
|
36
36
|
execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(child.pid)], { windowsHide: true }, error => {
|
|
37
37
|
// taskkill reports 128 when the process has already exited.
|
|
38
|
-
|
|
39
|
-
reject(error);
|
|
40
|
-
else
|
|
41
|
-
resolve();
|
|
38
|
+
resolve(error && error.code !== 128 ? error : undefined);
|
|
42
39
|
});
|
|
43
40
|
});
|
|
41
|
+
if (failure) {
|
|
42
|
+
// With /T, a descendant that exits during the tree walk makes taskkill
|
|
43
|
+
// fail although the shell was killed. Fail only if the shell survives.
|
|
44
|
+
const stopped = await Promise.race([
|
|
45
|
+
exited.then(() => true, () => true),
|
|
46
|
+
new Promise(resolve => setTimeout(() => resolve(false), 2000).unref()),
|
|
47
|
+
]);
|
|
48
|
+
if (!stopped)
|
|
49
|
+
throw failure;
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
else {
|
|
46
53
|
try {
|