@chrok/pi-braid 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/dist/assistant.js +345 -0
- package/dist/command.js +259 -31
- package/dist/display.js +17 -2
- package/dist/human-input.js +60 -0
- package/dist/jobs.js +47 -1
- package/dist/runner.js +19 -2
- package/dist/session-view.js +27 -1
- package/dist/shell-tools.js +12 -5
- package/dist/transcript.js +14 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ It registers:
|
|
|
26
26
|
- `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
|
|
27
27
|
- `/braid [jobId]` — open a live flow panel in interactive Pi.
|
|
28
28
|
- `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
|
|
29
|
+
- `/braid:clear` — clear the progress widget above the editor; run it again while
|
|
30
|
+
the widget is empty to restore what was cleared.
|
|
29
31
|
|
|
30
32
|
Submission and completion reminders use short session handles such as `job-1`.
|
|
31
33
|
Status, cancellation and the panel accept either that exact handle or the original
|
|
@@ -212,7 +214,10 @@ Braid job-1 · running · 2/5 done · review
|
|
|
212
214
|
The widget shows node progress, active node IDs, paused gates, and failures.
|
|
213
215
|
It shows up to three active jobs, with an overflow count for the rest. When no
|
|
214
216
|
jobs are active, the newest job's final completed/failed/cancelled status stays
|
|
215
|
-
visible until another job is submitted or the session ends.
|
|
217
|
+
visible until another job is submitted or the session ends. Run `/braid:clear`
|
|
218
|
+
to clear the widget: it clears every job submitted so far, including running
|
|
219
|
+
ones, and jobs submitted afterwards still appear. Running it again while the widget
|
|
220
|
+
is empty restores the most recently cleared jobs. Counts use the
|
|
216
221
|
latest state of each current node definition; failed and skipped nodes count as
|
|
217
222
|
done, and reruns or live edits can change progress and totals. RPC, JSON, and
|
|
218
223
|
print modes do not create a terminal widget.
|
|
@@ -241,9 +246,51 @@ activity (see below)—stays pinned at the top while the session scrolls. The
|
|
|
241
246
|
session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between
|
|
242
247
|
invocations of the same node (for example loop iterations), and ctrl+o expands
|
|
243
248
|
tool output. Escape steps back one level—session, then node selection, then the
|
|
244
|
-
graph—and closes the panel from the graph; `q` closes it from anywhere.
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
graph—and closes the panel from the graph; `q` closes it from anywhere. Running
|
|
250
|
+
invocations and the 20 most recently finished ones per job keep their session;
|
|
251
|
+
older ones show the summary and activity history.
|
|
252
|
+
|
|
253
|
+
### Assistant
|
|
254
|
+
|
|
255
|
+
Press Tab to open an assistant and Tab again to hide it. While it is open,
|
|
256
|
+
every key goes to it, including `q` and `c`; type a question and press Enter.
|
|
257
|
+
Escape stops an answer in progress, or hides the assistant. PgUp/PgDn or the
|
|
258
|
+
mouse wheel scroll the conversation. A panel at least 100 columns wide shows
|
|
259
|
+
the assistant beside the graph or session; a narrower one shows it in their
|
|
260
|
+
place.
|
|
261
|
+
|
|
262
|
+
Conversations are scoped. On the graph, the assistant talks about the whole
|
|
263
|
+
job. With a node selected (`v`) or its session open, it uses that node's own
|
|
264
|
+
conversation, shared by all of the node's executions; each question says which
|
|
265
|
+
execution is shown. Each scope keeps its history for the Pi session, including
|
|
266
|
+
while the assistant or the panel is hidden.
|
|
267
|
+
|
|
268
|
+
In fullscreen mode the mouse wheel also scrolls the left side: the graph, a
|
|
269
|
+
node session, or a node's details and history. Scrolling a session up stops it
|
|
270
|
+
following new output; scrolling back to the bottom resumes.
|
|
271
|
+
|
|
272
|
+
The assistant uses the main session's current model. It does not see the main
|
|
273
|
+
conversation; each question tells it what the panel shows (the graph, the
|
|
274
|
+
selected node, or the open session), and it reads the job through its own
|
|
275
|
+
tools: job status and graph, an execution's details and activity, and the
|
|
276
|
+
latest messages of a worker's conversation. It cannot edit the graph, cancel,
|
|
277
|
+
or resume; ask the main agent for that.
|
|
278
|
+
|
|
279
|
+
To steer a running execution, tell the assistant what it should do. The
|
|
280
|
+
assistant messages that execution for you, and only when you ask. The message
|
|
281
|
+
waits, shown as pending in the execution's session, and is delivered before the
|
|
282
|
+
worker's next model request. A tool batch that is running finishes first and is
|
|
283
|
+
never interrupted. If it arrives while the worker is writing its final answer,
|
|
284
|
+
the worker gets one more round to take it into account. The worker receives it
|
|
285
|
+
labelled as a message from the supervising human, separate from the node prompt
|
|
286
|
+
and predecessor outputs; it cannot lift tool, budget, workspace, or decision
|
|
287
|
+
constraints (a decision node that has already called `decide` keeps its
|
|
288
|
+
choice). Delivered messages appear in the session, in the activity history, in
|
|
289
|
+
the event log, and in the execution's `humanInputs` in `braid_status` results,
|
|
290
|
+
so it stays visible that the result did not come from the graph alone. Messages
|
|
291
|
+
still pending when the execution ends are marked as not delivered and are not
|
|
292
|
+
recorded as input. Finished executions, including those held by `pauseAfter`,
|
|
293
|
+
cannot be messaged.
|
|
247
294
|
|
|
248
295
|
Run `/braid <jobId> <nodeId>` to open that node's latest invocation directly in
|
|
249
296
|
the session view, or `/braid <jobId> <executionId>` for an exact invocation such
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { Type, validateToolCall, } from "@earendil-works/pi-ai";
|
|
2
|
+
import { truncateHead } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
/** Bounds a single answer, independently of how the model behaves. */
|
|
5
|
+
const MAX_ROUNDS = 24;
|
|
6
|
+
const ACTIVITY_ENTRIES = 40;
|
|
7
|
+
const TRANSCRIPT_MESSAGES = 20;
|
|
8
|
+
const MESSAGE_PREVIEW = 1500;
|
|
9
|
+
const id = (description) => Type.String({ minLength: 1, description });
|
|
10
|
+
const TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: "job_status",
|
|
13
|
+
description: "Read this job's goal, status, revision, graph definition (node prompts and edges), the latest result per node, and every execution with its status.",
|
|
14
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "execution_details",
|
|
18
|
+
description: "Read one execution: status, output or error, decision, workspace, usage, human inputs, and live activity (phase, model/tool waits, recent history). Pass executionId, or nodeId for the node's latest execution.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
executionId: Type.Optional(id("Exact execution ID.")),
|
|
21
|
+
nodeId: Type.Optional(id("Node ID; reads its latest execution.")),
|
|
22
|
+
activityBefore: Type.Optional(Type.Integer({ minimum: 1, description: "Page activity entries before this sequence number." })),
|
|
23
|
+
}, { additionalProperties: false }),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "execution_transcript",
|
|
27
|
+
description: "Read the latest messages of one execution's worker conversation: assistant text, tool calls and results, and human messages. Available while it runs and for recently finished executions.",
|
|
28
|
+
parameters: Type.Object({
|
|
29
|
+
executionId: id("Exact execution ID."),
|
|
30
|
+
last: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: `Number of latest messages; default ${TRANSCRIPT_MESSAGES}.` })),
|
|
31
|
+
}, { additionalProperties: false }),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "message_execution",
|
|
35
|
+
description: "Send a message to a running execution's worker on the user's behalf. It is delivered before the worker's next model request (a running tool batch finishes first) and is recorded as human input on the execution. Use only when the user asks to guide, correct, or inform a running execution. Write a self-contained instruction the worker can act on; it cannot see this conversation.",
|
|
36
|
+
parameters: Type.Object({
|
|
37
|
+
executionId: id("Exact ID of a running execution."),
|
|
38
|
+
message: id("The instruction for the worker."),
|
|
39
|
+
}, { additionalProperties: false }),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
const short = (value) => typeof value === "string" ? (value.length > 13 ? `${value.slice(0, 8)}…` : value) : "?";
|
|
43
|
+
const target = (args) => args.executionId !== undefined ? `execution ${short(args.executionId)}` : args.nodeId !== undefined ? `node ${JSON.stringify(args.nodeId)}` : "";
|
|
44
|
+
/** Compact tool cards: what was read or sent, with full results on ctrl+o. */
|
|
45
|
+
function renderer(call) {
|
|
46
|
+
return {
|
|
47
|
+
renderCall: (args, theme) => new Text(call(args ?? {}, theme), 0, 0),
|
|
48
|
+
renderResult: (result, options, theme) => {
|
|
49
|
+
const text = textOf(result.content);
|
|
50
|
+
const first = text.split("\n").find(line => line.trim()) ?? "";
|
|
51
|
+
return new Text(options.expanded ? text : theme.fg("dim", first.length > 160 ? `${first.slice(0, 159)}…` : first), 0, 0);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const RENDERERS = new Map([
|
|
56
|
+
["job_status", renderer((_args, theme) => theme.fg("toolTitle", theme.bold("Read job status")))],
|
|
57
|
+
["execution_details", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read details")) + " " + theme.fg("muted", target(args)))],
|
|
58
|
+
["execution_transcript", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read conversation")) + " " + theme.fg("muted", `of ${target(args)}`))],
|
|
59
|
+
["message_execution", renderer((args, theme) => theme.fg("warning", theme.bold("✎ Message")) + " " + theme.fg("muted", `to ${target(args)}`) +
|
|
60
|
+
(typeof args.message === "string" ? `\n${args.message}` : ""))],
|
|
61
|
+
]);
|
|
62
|
+
const clip = (text, max = MESSAGE_PREVIEW) => text.length <= max ? text : `${text.slice(0, max)}… [${text.length - max} more characters]`;
|
|
63
|
+
const textOf = (content) => content.flatMap(block => block.type === "text" && block.text ? [block.text] : []).join("\n");
|
|
64
|
+
function describeTranscript(transcript, last) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
const messages = transcript.messages.slice(-last);
|
|
67
|
+
if (messages.length < transcript.messages.length)
|
|
68
|
+
lines.push(`[${transcript.messages.length - messages.length} earlier messages omitted]`);
|
|
69
|
+
else
|
|
70
|
+
lines.push(`[node prompt] ${clip(transcript.intro.prompt)}`);
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
if (message.role === "human")
|
|
73
|
+
lines.push(`[human input · ${message.delivered ? "delivered" : "not delivered"}] ${clip(message.text)}`);
|
|
74
|
+
else if (message.role === "toolResult")
|
|
75
|
+
lines.push(`[${message.toolName} ${message.isError ? "error" : "result"}] ${clip(textOf(message.content), 800)}`);
|
|
76
|
+
else if (message.role === "assistant") {
|
|
77
|
+
for (const block of message.content) {
|
|
78
|
+
if (block.type === "text" && block.text.trim())
|
|
79
|
+
lines.push(`[assistant] ${clip(block.text)}`);
|
|
80
|
+
else if (block.type === "toolCall")
|
|
81
|
+
lines.push(`[tool call ${block.name}] ${clip(JSON.stringify(block.arguments), 400)}`);
|
|
82
|
+
}
|
|
83
|
+
if (message.stopReason === "error" || message.stopReason === "aborted")
|
|
84
|
+
lines.push(`[${message.stopReason}] ${message.errorMessage ?? ""}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (transcript.streaming)
|
|
88
|
+
lines.push(`[assistant, streaming] ${clip(textOf(transcript.streaming.content))}`);
|
|
89
|
+
for (const callId of transcript.running.keys())
|
|
90
|
+
lines.push(`[tool running] ${callId}`);
|
|
91
|
+
lines.push(transcript.finished ? "[execution finished]" : "[execution running]");
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A side conversation about one Braid job, or about one node of it. Each
|
|
96
|
+
* scope keeps its own history. It inspects the job through
|
|
97
|
+
* read-only tools and can message running executions; graph changes,
|
|
98
|
+
* cancellation, and resumption stay with the main agent.
|
|
99
|
+
*/
|
|
100
|
+
export class BraidAssistant {
|
|
101
|
+
jobs;
|
|
102
|
+
jobId;
|
|
103
|
+
onChange;
|
|
104
|
+
nodeId;
|
|
105
|
+
conversation;
|
|
106
|
+
history = [];
|
|
107
|
+
queued = [];
|
|
108
|
+
controller;
|
|
109
|
+
pending;
|
|
110
|
+
disposed = false;
|
|
111
|
+
constructor(jobs, jobId, cwd, onChange,
|
|
112
|
+
/** The node this conversation is about; absent for the whole job. */
|
|
113
|
+
nodeId) {
|
|
114
|
+
this.jobs = jobs;
|
|
115
|
+
this.jobId = jobId;
|
|
116
|
+
this.onChange = onChange;
|
|
117
|
+
this.nodeId = nodeId;
|
|
118
|
+
this.conversation = { cwd, messages: [], running: new Map(), renderers: RENDERERS, version: 0 };
|
|
119
|
+
}
|
|
120
|
+
get busy() {
|
|
121
|
+
return this.controller !== undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Ask a question or request an intervention. `view` describes what the panel shows. */
|
|
124
|
+
send(text, view, model) {
|
|
125
|
+
if (this.disposed || !text.trim())
|
|
126
|
+
return;
|
|
127
|
+
this.conversation.messages.push({ role: "user", content: text, timestamp: Date.now() });
|
|
128
|
+
this.queued.push({ text, view });
|
|
129
|
+
this.changed();
|
|
130
|
+
if (!this.controller)
|
|
131
|
+
void this.run(model);
|
|
132
|
+
}
|
|
133
|
+
/** Stop the current answer; queued questions are dropped. */
|
|
134
|
+
abort() {
|
|
135
|
+
this.queued = [];
|
|
136
|
+
this.controller?.abort();
|
|
137
|
+
}
|
|
138
|
+
dispose() {
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
this.abort();
|
|
141
|
+
clearTimeout(this.pending);
|
|
142
|
+
}
|
|
143
|
+
systemPrompt() {
|
|
144
|
+
const job = this.jobs.get(this.jobId);
|
|
145
|
+
return "You are the Braid assistant inside the /braid panel of Pi. You help the user understand and steer one background Braid job " +
|
|
146
|
+
`(${job?.handle ?? this.jobId}: ${JSON.stringify(job?.goal ?? "")}). ` +
|
|
147
|
+
"A Braid job is a graph of nodes; each run of a node is an execution with its own isolated worker conversation. " +
|
|
148
|
+
"Use your tools to inspect the job instead of guessing, and cite node and execution IDs. " +
|
|
149
|
+
"When the user asks to guide, correct, or inform a running execution, use message_execution with a clear, self-contained instruction. " +
|
|
150
|
+
"If it is unclear which execution they mean, or none is running, say so instead of guessing. Never message an execution on your own initiative. " +
|
|
151
|
+
"Messages reach the worker before its next model request; they cannot change finished executions, the graph, or a decision already made. " +
|
|
152
|
+
"You cannot edit the graph, cancel, or resume; tell the user to ask the main Pi agent for that. " +
|
|
153
|
+
"You do not see the main conversation. Answer concisely, in the user's language." +
|
|
154
|
+
(this.nodeId === undefined
|
|
155
|
+
? " This conversation is about the whole job; separate conversations exist for individual nodes."
|
|
156
|
+
: ` This conversation is about node ${JSON.stringify(this.nodeId)} and all of its executions; when the user says "it" or "this node", they mean this node. ` +
|
|
157
|
+
"Each question says which execution the panel shows. You may still read other nodes for context, but message only this node's executions unless the user names another.");
|
|
158
|
+
}
|
|
159
|
+
async run(model) {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
this.controller = controller;
|
|
162
|
+
try {
|
|
163
|
+
while (this.queued.length && !controller.signal.aborted) {
|
|
164
|
+
for (const { text, view } of this.queued.splice(0)) {
|
|
165
|
+
this.history.push({ role: "user", content: `[Panel view: ${view}]\n\n${text}`, timestamp: Date.now() });
|
|
166
|
+
}
|
|
167
|
+
await this.answer(model, controller.signal);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
this.closeToolCalls();
|
|
173
|
+
this.fail(controller.signal.aborted ? "aborted" : "error", controller.signal.aborted ? "Stopped" : message);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.controller = undefined;
|
|
177
|
+
this.conversation.running.clear();
|
|
178
|
+
this.changed();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async answer(source, signal) {
|
|
182
|
+
const model = source.model;
|
|
183
|
+
if (!model)
|
|
184
|
+
throw new Error("Choose a Pi model with /model to use the Braid assistant");
|
|
185
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
const context = { systemPrompt: this.systemPrompt(), messages: this.history, tools: TOOLS };
|
|
188
|
+
const options = { signal, maxRetries: 0 };
|
|
189
|
+
let response;
|
|
190
|
+
if (source.registry.stream) {
|
|
191
|
+
const stream = source.registry.stream(model, context, options);
|
|
192
|
+
for await (const event of stream) {
|
|
193
|
+
if (event.type === "done" || event.type === "error")
|
|
194
|
+
continue;
|
|
195
|
+
this.conversation.streaming = event.partial;
|
|
196
|
+
this.changed(true);
|
|
197
|
+
}
|
|
198
|
+
response = await stream.result();
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
response = await source.registry.complete(model, context, options);
|
|
202
|
+
}
|
|
203
|
+
delete this.conversation.streaming;
|
|
204
|
+
this.conversation.messages.push(response);
|
|
205
|
+
this.changed();
|
|
206
|
+
if (response.stopReason === "aborted")
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
// Failed responses are shown, but never sent back to the provider.
|
|
209
|
+
if (response.stopReason === "error" || response.stopReason === "aborted")
|
|
210
|
+
return;
|
|
211
|
+
this.history.push(response);
|
|
212
|
+
const calls = response.content.filter((block) => block.type === "toolCall");
|
|
213
|
+
if (!calls.length)
|
|
214
|
+
return;
|
|
215
|
+
for (const call of calls) {
|
|
216
|
+
signal.throwIfAborted();
|
|
217
|
+
this.conversation.running.set(call.id, undefined);
|
|
218
|
+
this.changed();
|
|
219
|
+
const result = this.execute(call);
|
|
220
|
+
this.conversation.running.delete(call.id);
|
|
221
|
+
this.history.push(result);
|
|
222
|
+
this.conversation.messages.push(result);
|
|
223
|
+
this.changed();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.fail("error", `Stopped after ${MAX_ROUNDS} tool rounds`);
|
|
227
|
+
}
|
|
228
|
+
execute(call) {
|
|
229
|
+
let text;
|
|
230
|
+
let isError = false;
|
|
231
|
+
try {
|
|
232
|
+
const args = validateToolCall(TOOLS, call);
|
|
233
|
+
text = this.tool(call.name, args);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
text = error instanceof Error ? error.message : String(error);
|
|
237
|
+
isError = true;
|
|
238
|
+
}
|
|
239
|
+
return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError, timestamp: Date.now() };
|
|
240
|
+
}
|
|
241
|
+
tool(name, args) {
|
|
242
|
+
const job = this.jobs.get(this.jobId);
|
|
243
|
+
if (!job)
|
|
244
|
+
throw this.jobs.unknownJob(this.jobId);
|
|
245
|
+
const preview = (value) => {
|
|
246
|
+
const full = JSON.stringify(value, null, 2);
|
|
247
|
+
const result = truncateHead(full);
|
|
248
|
+
return result.truncated ? `${result.content}\n[Truncated; ask about a specific node or execution.]` : full;
|
|
249
|
+
};
|
|
250
|
+
switch (name) {
|
|
251
|
+
case "job_status": {
|
|
252
|
+
const state = job.result ?? job.execution;
|
|
253
|
+
return preview({
|
|
254
|
+
handle: job.handle, goal: job.goal, status: job.status, error: job.error ?? state?.error,
|
|
255
|
+
revision: job.execution?.revision, pausedExecutionIds: job.execution?.pausedExecutionIds,
|
|
256
|
+
graph: job.execution?.graph,
|
|
257
|
+
nodes: state?.nodes,
|
|
258
|
+
executions: Object.values(state?.executions ?? {}).map(execution => ({
|
|
259
|
+
executionId: execution.executionId, nodeId: execution.id, status: execution.status,
|
|
260
|
+
...(execution.iteration ? { iteration: execution.iteration } : {}),
|
|
261
|
+
...(execution.humanInputs?.length ? { humanInputs: execution.humanInputs.length } : {}),
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
case "execution_details": {
|
|
266
|
+
if (args.executionId === undefined && args.nodeId === undefined)
|
|
267
|
+
throw new Error("Pass executionId or nodeId");
|
|
268
|
+
const node = this.jobs.getNode(this.jobId, args.nodeId, args.executionId);
|
|
269
|
+
const activity = node.executionId ? this.jobs.getActivity(this.jobId, node.executionId, {
|
|
270
|
+
limit: ACTIVITY_ENTRIES, ...(args.activityBefore === undefined ? {} : { before: args.activityBefore }),
|
|
271
|
+
}) : undefined;
|
|
272
|
+
return preview({ node, ...(activity ? { activity } : {}) });
|
|
273
|
+
}
|
|
274
|
+
case "execution_transcript": {
|
|
275
|
+
const transcript = this.jobs.getTranscript(this.jobId, args.executionId);
|
|
276
|
+
if (!transcript)
|
|
277
|
+
throw new Error("No live conversation is retained for this execution; use execution_details for its output and activity history.");
|
|
278
|
+
return describeTranscript(transcript, args.last ?? TRANSCRIPT_MESSAGES);
|
|
279
|
+
}
|
|
280
|
+
case "message_execution": {
|
|
281
|
+
this.jobs.sendInput(this.jobId, args.executionId, args.message);
|
|
282
|
+
return "Queued. The worker receives it before its next model request, after any running tool batch.";
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
throw new Error(`Unknown tool ${name}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Answer tool calls interrupted by an abort, so the next request is well formed. */
|
|
289
|
+
closeToolCalls() {
|
|
290
|
+
let index = this.history.length - 1;
|
|
291
|
+
while (index >= 0 && this.history[index].role !== "assistant")
|
|
292
|
+
index--;
|
|
293
|
+
const last = this.history[index];
|
|
294
|
+
if (last?.role !== "assistant")
|
|
295
|
+
return;
|
|
296
|
+
const answered = new Set(this.history.slice(index + 1).flatMap(message => message.role === "toolResult" ? [message.toolCallId] : []));
|
|
297
|
+
for (const block of last.content) {
|
|
298
|
+
if (block.type !== "toolCall" || answered.has(block.id))
|
|
299
|
+
continue;
|
|
300
|
+
const result = {
|
|
301
|
+
role: "toolResult", toolCallId: block.id, toolName: block.name,
|
|
302
|
+
content: [{ type: "text", text: "Stopped by the user before this tool ran" }], isError: true, timestamp: Date.now(),
|
|
303
|
+
};
|
|
304
|
+
this.history.push(result);
|
|
305
|
+
this.conversation.messages.push(result);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
fail(stopReason, errorMessage) {
|
|
309
|
+
const partial = this.conversation.streaming;
|
|
310
|
+
delete this.conversation.streaming;
|
|
311
|
+
const message = {
|
|
312
|
+
role: "assistant", api: partial?.api ?? "braid", provider: partial?.provider ?? "braid", model: partial?.model ?? "assistant",
|
|
313
|
+
content: partial?.content ?? [], stopReason, errorMessage, timestamp: Date.now(),
|
|
314
|
+
usage: partial?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
315
|
+
};
|
|
316
|
+
// The model sees what was said, without a dangling unanswered tool call.
|
|
317
|
+
if (message.content.some(block => block.type === "toolCall"))
|
|
318
|
+
message.content = message.content.filter(block => block.type !== "toolCall");
|
|
319
|
+
this.conversation.messages.push(message);
|
|
320
|
+
if (message.content.length)
|
|
321
|
+
this.history.push({ ...message, stopReason: "stop" });
|
|
322
|
+
this.changed();
|
|
323
|
+
}
|
|
324
|
+
/** Token bursts coalesce into at most one notification per 100 ms. */
|
|
325
|
+
changed(burst = false) {
|
|
326
|
+
this.conversation.version++;
|
|
327
|
+
if (!burst) {
|
|
328
|
+
clearTimeout(this.pending);
|
|
329
|
+
this.pending = undefined;
|
|
330
|
+
this.emit();
|
|
331
|
+
}
|
|
332
|
+
else if (!this.pending) {
|
|
333
|
+
this.pending = setTimeout(() => { this.pending = undefined; this.emit(); }, 100);
|
|
334
|
+
this.pending.unref?.();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
emit() {
|
|
338
|
+
if (this.disposed)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
this.onChange();
|
|
342
|
+
}
|
|
343
|
+
catch { /* Observers cannot fail the assistant. */ }
|
|
344
|
+
}
|
|
345
|
+
}
|
package/dist/command.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Input, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
2
|
import { renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK } from "./display.js";
|
|
3
3
|
import { NodeSessionView } from "./session-view.js";
|
|
4
4
|
// Match the overlay height exactly so Pi never clips the footer or bottom border.
|
|
5
5
|
const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
|
|
6
6
|
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
7
|
+
/** Below this content width the graph and the assistant share the body, switched with Tab. */
|
|
8
|
+
const SPLIT_WIDTH = 100;
|
|
9
|
+
/** Marks a separator row so it can span the whole panel or only the left column. */
|
|
10
|
+
const SEPARATOR = "\u0000separator";
|
|
7
11
|
/**
|
|
8
12
|
* A live, scrollable panel. Esc steps back one level: session → node selection →
|
|
9
|
-
* graph → close. Closing the panel leaves jobs running.
|
|
13
|
+
* graph → close. Closing the panel leaves jobs running. Tab toggles an assistant
|
|
14
|
+
* that answers questions about the job and can message running executions.
|
|
10
15
|
*/
|
|
11
16
|
export class BraidPanel {
|
|
12
17
|
jobs;
|
|
13
18
|
tui;
|
|
14
19
|
theme;
|
|
15
20
|
done;
|
|
21
|
+
assistantModel;
|
|
16
22
|
selected;
|
|
17
23
|
focus;
|
|
18
24
|
/** Node highlighted in selection mode (`v`); undefined in the plain graph view. */
|
|
@@ -24,18 +30,46 @@ export class BraidPanel {
|
|
|
24
30
|
offset = 0;
|
|
25
31
|
maxOffset = 0;
|
|
26
32
|
disposed = false;
|
|
33
|
+
/** While open, the assistant receives keys; it shares a wide panel and fills a narrow one. */
|
|
34
|
+
assistantOpen = false;
|
|
35
|
+
/** Where the body sits in the last frame, for routing mouse wheel events. */
|
|
36
|
+
layout;
|
|
37
|
+
/** Assistant lines scrolled up from the newest. */
|
|
38
|
+
chatOffset = 0;
|
|
39
|
+
chatMaxOffset = 0;
|
|
40
|
+
chatScope;
|
|
41
|
+
input;
|
|
27
42
|
session;
|
|
43
|
+
chat;
|
|
28
44
|
unsubscribe;
|
|
29
45
|
ticker;
|
|
30
|
-
constructor(jobs, tui, theme, done, jobId, focus) {
|
|
46
|
+
constructor(jobs, tui, theme, done, jobId, focus, assistantModel = () => ({ registry: { complete: () => Promise.reject(new Error("No Pi model registry")) } })) {
|
|
31
47
|
this.jobs = jobs;
|
|
32
48
|
this.tui = tui;
|
|
33
49
|
this.theme = theme;
|
|
34
50
|
this.done = done;
|
|
51
|
+
this.assistantModel = assistantModel;
|
|
35
52
|
this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
|
|
36
53
|
this.focus = focus;
|
|
37
54
|
// Pi's tool components only request renders through the TUI.
|
|
38
55
|
this.session = new NodeSessionView(tui, theme);
|
|
56
|
+
this.chat = new NodeSessionView(tui, theme);
|
|
57
|
+
this.input = new Input({
|
|
58
|
+
prompt: theme.fg("accent", "› "),
|
|
59
|
+
placeholder: "Ask, or say how to steer…",
|
|
60
|
+
placeholderStyle: text => theme.fg("dim", text),
|
|
61
|
+
});
|
|
62
|
+
this.input.onSubmit = value => {
|
|
63
|
+
if (!value.trim() || !this.selected)
|
|
64
|
+
return;
|
|
65
|
+
const job = this.jobs.get(this.selected);
|
|
66
|
+
if (!job)
|
|
67
|
+
return;
|
|
68
|
+
this.jobs.assistant(job.jobId, this.assistantNode(job)).send(value, this.describeView(job), this.assistantModel());
|
|
69
|
+
this.input.setValue("");
|
|
70
|
+
this.chatOffset = 0;
|
|
71
|
+
this.refresh();
|
|
72
|
+
};
|
|
39
73
|
this.unsubscribe = jobs.subscribe(() => this.refresh());
|
|
40
74
|
this.ticker = setInterval(() => this.refresh(), 1000);
|
|
41
75
|
this.ticker.unref();
|
|
@@ -108,16 +142,56 @@ export class BraidPanel {
|
|
|
108
142
|
if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) {
|
|
109
143
|
this.choosing = this.nodeIds(current)[0];
|
|
110
144
|
}
|
|
145
|
+
if (!current)
|
|
146
|
+
this.assistantOpen = false;
|
|
147
|
+
const available = rows - header.length - 4;
|
|
148
|
+
const open = current !== undefined && this.assistantOpen;
|
|
149
|
+
const split = open && contentWidth >= SPLIT_WIDTH;
|
|
150
|
+
const leftWidth = split ? Math.floor((contentWidth - 3) * 0.6) : contentWidth;
|
|
151
|
+
const chatWidth = split ? contentWidth - leftWidth - 3 : contentWidth;
|
|
152
|
+
const graph = !open || split ? this.renderGraphPane(current, leftWidth, available) : undefined;
|
|
153
|
+
const chat = open ? this.renderAssistant(current, chatWidth, available) : [];
|
|
154
|
+
// Columns: border and gutter, then the left pane, " │ ", then the assistant.
|
|
155
|
+
this.layout = { top: header.length, height: available,
|
|
156
|
+
...(open ? { chatLeft: split ? 2 + leftWidth + 3 : 2 } : {}) };
|
|
157
|
+
const body = split
|
|
158
|
+
? graph.lines.map((line, at) => (line === SEPARATOR ? border("─".repeat(leftWidth)) : truncateToWidth(line, leftWidth, "…", true)) +
|
|
159
|
+
" " + border("│") + " " + (chat[at] ?? ""))
|
|
160
|
+
: graph?.lines ?? chat;
|
|
161
|
+
const tab = current ? "Tab assistant " : "";
|
|
162
|
+
const [wide, medium, narrow] = open
|
|
163
|
+
? ["Enter send Esc stop or hide Tab hide PgUp/PgDn or wheel scroll ctrl+c close",
|
|
164
|
+
"Enter send · Esc hide · Tab hide", "Enter send · Tab hide"]
|
|
165
|
+
: this.focus
|
|
166
|
+
? [`${tab}Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close`,
|
|
167
|
+
"Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
|
|
168
|
+
: this.choosing !== undefined
|
|
169
|
+
? [`${tab}↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close`,
|
|
170
|
+
"↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
|
|
171
|
+
: [`${tab}v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close`,
|
|
172
|
+
"v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
|
|
173
|
+
const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
|
|
174
|
+
const status = graph ? graph.range : "Assistant";
|
|
175
|
+
return [
|
|
176
|
+
...header,
|
|
177
|
+
...body.map(line => line === SEPARATOR ? rule("├", "┤") : row(line)),
|
|
178
|
+
rule("├", "┤"),
|
|
179
|
+
row(this.theme.fg("dim", `${status}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
180
|
+
row(this.theme.fg("muted", help)),
|
|
181
|
+
rule("╰", "╯"),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
/** The graph, node selection, or a node session: pinned summary, separator, scrolled body. */
|
|
185
|
+
renderGraphPane(current, width, available) {
|
|
111
186
|
// The node summary (or the selected node) stays pinned while the body scrolls.
|
|
112
|
-
const focused = current && this.focus ? this.renderFocus(current, this.focus,
|
|
187
|
+
const focused = current && this.focus ? this.renderFocus(current, this.focus, width) : undefined;
|
|
113
188
|
let pinned = focused?.pinned ?? (current && this.choosing !== undefined ? [this.choosingLine(current)] : []);
|
|
114
|
-
const available = rows - header.length - 4;
|
|
115
189
|
const maxPinned = Math.max(2, Math.floor(available / 2));
|
|
116
190
|
if (pinned.length > maxPinned) {
|
|
117
191
|
pinned = [...pinned.slice(0, maxPinned - 1), this.theme.fg("dim", `… ${pinned.length - maxPinned + 1} more summary lines`)];
|
|
118
192
|
}
|
|
119
193
|
const content = focused
|
|
120
|
-
? focused.body
|
|
194
|
+
? [...focused.body, ...(current && focused.executionId ? this.pendingLines(current, focused.executionId) : [])]
|
|
121
195
|
: current
|
|
122
196
|
? [
|
|
123
197
|
...(current.error
|
|
@@ -130,7 +204,7 @@ export class BraidPanel {
|
|
|
130
204
|
...(current.fullOutputPath
|
|
131
205
|
? { fullOutputPath: current.fullOutputPath }
|
|
132
206
|
: {}),
|
|
133
|
-
}, true, false, this.theme, "", false, this.choosing).render(
|
|
207
|
+
}, true, false, this.theme, "", false, this.choosing).render(width),
|
|
134
208
|
]
|
|
135
209
|
: ["No Braid jobs in this session."];
|
|
136
210
|
const height = available - (pinned.length ? pinned.length + 1 : 0);
|
|
@@ -149,26 +223,96 @@ export class BraidPanel {
|
|
|
149
223
|
const body = content.slice(this.offset, this.offset + height);
|
|
150
224
|
while (body.length < height)
|
|
151
225
|
body.push("");
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
226
|
+
return {
|
|
227
|
+
lines: [...(pinned.length ? [...pinned, SEPARATOR] : []), ...body],
|
|
228
|
+
range: `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** The job's assistant conversation, newest lines at the bottom, above its input line. */
|
|
232
|
+
renderAssistant(job, width, height) {
|
|
233
|
+
const nodeId = this.assistantNode(job);
|
|
234
|
+
const assistant = this.jobs.assistant(job.jobId, nodeId);
|
|
235
|
+
// Each scope has its own conversation; a new one starts at its newest lines.
|
|
236
|
+
const scope = JSON.stringify([job.jobId, nodeId ?? null]);
|
|
237
|
+
if (scope !== this.chatScope) {
|
|
238
|
+
this.chatScope = scope;
|
|
239
|
+
this.chatOffset = 0;
|
|
240
|
+
}
|
|
241
|
+
const title = this.theme.fg("accent", this.theme.bold(nodeId === undefined ? "Assistant · job" : `Assistant · node ${plain(nodeId)}`)) +
|
|
242
|
+
this.theme.fg("muted", assistant.busy ? " · working… Esc to stop" : " · Tab to hide");
|
|
243
|
+
let lines;
|
|
244
|
+
try {
|
|
245
|
+
lines = assistant.conversation.messages.length
|
|
246
|
+
? this.chat.render(assistant.conversation, width)
|
|
247
|
+
: [
|
|
248
|
+
// Wrapped, not truncated, and kept off the input line like a finished reply.
|
|
249
|
+
...[
|
|
250
|
+
nodeId === undefined
|
|
251
|
+
? "Ask what a node is doing, why it failed, or what it produced."
|
|
252
|
+
: `Ask what ${plain(nodeId)} is doing, why it failed, or what it produced.`,
|
|
253
|
+
"To change a running execution's direction, say what it should do; the assistant messages that execution for you.",
|
|
254
|
+
].flatMap(hint => wrapTextWithAnsi(this.theme.fg("dim", hint), width)),
|
|
255
|
+
"",
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
lines = [this.theme.fg("error", `Assistant view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`)];
|
|
260
|
+
}
|
|
261
|
+
const visible = Math.max(1, height - 3);
|
|
262
|
+
this.chatMaxOffset = Math.max(0, lines.length - visible);
|
|
263
|
+
this.chatOffset = Math.min(this.chatOffset, this.chatMaxOffset);
|
|
264
|
+
const end = lines.length - this.chatOffset;
|
|
265
|
+
const body = lines.slice(Math.max(0, end - visible), end);
|
|
266
|
+
while (body.length < visible)
|
|
267
|
+
body.unshift("");
|
|
268
|
+
this.input.focused = true;
|
|
269
|
+
const input = this.input.render(width)[0] ?? "";
|
|
270
|
+
return [title, this.theme.fg("borderMuted", "─".repeat(width)), ...body, input].slice(0, height);
|
|
271
|
+
}
|
|
272
|
+
/** Messages the assistant sent that this execution has not received yet. */
|
|
273
|
+
pendingLines(job, executionId) {
|
|
274
|
+
let pending = [];
|
|
275
|
+
try {
|
|
276
|
+
pending = this.jobs.pendingInputs(job.jobId, executionId);
|
|
277
|
+
}
|
|
278
|
+
catch { /* The job was removed; show nothing. */ }
|
|
279
|
+
if (!pending.length)
|
|
280
|
+
return [];
|
|
162
281
|
return [
|
|
163
|
-
...
|
|
164
|
-
|
|
165
|
-
...body.map(row),
|
|
166
|
-
rule("├", "┤"),
|
|
167
|
-
row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
168
|
-
row(this.theme.fg("muted", help)),
|
|
169
|
-
rule("╰", "╯"),
|
|
282
|
+
...pending.map(input => this.theme.fg("warning", `⧗ pending human input · ${plain(input.text)}`)),
|
|
283
|
+
this.theme.fg("dim", " Delivered before the worker's next model request; running tools are not interrupted."),
|
|
170
284
|
];
|
|
171
285
|
}
|
|
286
|
+
/** The node whose conversation the assistant shows: the open session's, or the selected one. */
|
|
287
|
+
assistantNode(job) {
|
|
288
|
+
if (!this.focus)
|
|
289
|
+
return this.choosing;
|
|
290
|
+
if ("nodeId" in this.focus)
|
|
291
|
+
return this.focus.nodeId;
|
|
292
|
+
try {
|
|
293
|
+
return this.jobs.getNode(job.jobId, undefined, this.focus.executionId).id;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** What the user is looking at, so "this node" means something to the assistant. */
|
|
300
|
+
describeView(job) {
|
|
301
|
+
if (this.focus) {
|
|
302
|
+
try {
|
|
303
|
+
const node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
|
|
304
|
+
return `session of node ${JSON.stringify(node.id)}${node.executionId ? `, execution ${node.executionId}` : ""} (${node.status})`;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return "a node that is no longer in the graph";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (this.choosing !== undefined) {
|
|
311
|
+
const node = (job.result?.nodes ?? job.live.nodes)[this.choosing];
|
|
312
|
+
return `job graph with node ${JSON.stringify(this.choosing)} selected (${node?.status ?? "pending"})`;
|
|
313
|
+
}
|
|
314
|
+
return `job graph overview (${job.status})`;
|
|
315
|
+
}
|
|
172
316
|
/** Graph definition order, which is also the flowchart's declaration order. */
|
|
173
317
|
nodeIds(job) {
|
|
174
318
|
return Object.keys(job.result?.nodes ?? job.live.nodes);
|
|
@@ -210,7 +354,7 @@ export class BraidPanel {
|
|
|
210
354
|
let failure;
|
|
211
355
|
if (transcript) {
|
|
212
356
|
try {
|
|
213
|
-
return { pinned: header, body: this.session.render(transcript, width) };
|
|
357
|
+
return { pinned: header, body: this.session.render(transcript, width), executionId: node.executionId };
|
|
214
358
|
}
|
|
215
359
|
catch (error) {
|
|
216
360
|
// A rendering failure must not take down the overlay; fall back to the summary.
|
|
@@ -234,7 +378,21 @@ export class BraidPanel {
|
|
|
234
378
|
this.offset = 0;
|
|
235
379
|
}
|
|
236
380
|
handleInput(data) {
|
|
237
|
-
if (
|
|
381
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
382
|
+
this.close();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (matchesKey(data, "tab") && this.selected) {
|
|
386
|
+
this.assistantOpen = !this.assistantOpen;
|
|
387
|
+
this.refresh();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (this.assistantOpen) {
|
|
391
|
+
this.assistantInput(data);
|
|
392
|
+
this.refresh();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (data === "q") {
|
|
238
396
|
this.close();
|
|
239
397
|
return;
|
|
240
398
|
}
|
|
@@ -314,6 +472,44 @@ export class BraidPanel {
|
|
|
314
472
|
else
|
|
315
473
|
this.scroll(data);
|
|
316
474
|
}
|
|
475
|
+
/** The wheel scrolls whichever side it is over; other mouse events are left to Pi. */
|
|
476
|
+
handleMouse(event) {
|
|
477
|
+
const layout = this.layout;
|
|
478
|
+
if (event.type !== "wheel" || !event.wheelDelta || !layout)
|
|
479
|
+
return undefined;
|
|
480
|
+
if (event.y < layout.top || event.y >= layout.top + layout.height)
|
|
481
|
+
return undefined;
|
|
482
|
+
if (layout.chatLeft !== undefined && event.x >= layout.chatLeft - 1) {
|
|
483
|
+
this.chatOffset = Math.max(0, Math.min(this.chatMaxOffset, this.chatOffset - event.wheelDelta));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const before = this.offset;
|
|
487
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, this.offset + event.wheelDelta));
|
|
488
|
+
if (this.offset < before)
|
|
489
|
+
this.follow = false;
|
|
490
|
+
else if (this.offset >= this.maxOffset)
|
|
491
|
+
this.follow = true;
|
|
492
|
+
}
|
|
493
|
+
this.refresh();
|
|
494
|
+
return { handled: true };
|
|
495
|
+
}
|
|
496
|
+
/** Every key belongs to the question, including q and c. */
|
|
497
|
+
assistantInput(data) {
|
|
498
|
+
const job = this.selected ? this.jobs.get(this.selected) : undefined;
|
|
499
|
+
const assistant = job ? this.jobs.assistant(job.jobId, this.assistantNode(job)) : undefined;
|
|
500
|
+
if (matchesKey(data, "escape")) {
|
|
501
|
+
if (assistant?.busy)
|
|
502
|
+
assistant.abort();
|
|
503
|
+
else
|
|
504
|
+
this.assistantOpen = false;
|
|
505
|
+
}
|
|
506
|
+
else if (matchesKey(data, "pageUp"))
|
|
507
|
+
this.chatOffset = Math.min(this.chatMaxOffset, this.chatOffset + 10);
|
|
508
|
+
else if (matchesKey(data, "pageDown"))
|
|
509
|
+
this.chatOffset = Math.max(0, this.chatOffset - 10);
|
|
510
|
+
else
|
|
511
|
+
this.input.handleInput(data);
|
|
512
|
+
}
|
|
317
513
|
scroll(data) {
|
|
318
514
|
const before = this.offset;
|
|
319
515
|
if (matchesKey(data, "up"))
|
|
@@ -348,23 +544,54 @@ export class BraidPanel {
|
|
|
348
544
|
}
|
|
349
545
|
}
|
|
350
546
|
export function registerBraidWidget(pi, jobs) {
|
|
547
|
+
// Clearing hides the jobs present at that moment; jobs submitted later still show.
|
|
548
|
+
const cleared = new Set();
|
|
549
|
+
let lastCleared = [];
|
|
550
|
+
const shown = () => jobs.list().filter(job => !cleared.has(job.jobId));
|
|
551
|
+
let refresh = () => { };
|
|
351
552
|
let dispose = () => { };
|
|
352
553
|
const clear = () => {
|
|
353
554
|
dispose();
|
|
354
555
|
dispose = () => { };
|
|
556
|
+
refresh = () => { };
|
|
355
557
|
};
|
|
558
|
+
pi.registerCommand("braid:clear", {
|
|
559
|
+
description: "Clear the Braid progress widget; run again while it is empty to restore it",
|
|
560
|
+
handler: async (_args, ctx) => {
|
|
561
|
+
if (ctx.mode !== "tui")
|
|
562
|
+
throw new Error("The Braid progress widget requires interactive Pi.");
|
|
563
|
+
const visible = shown();
|
|
564
|
+
if (visible.length) {
|
|
565
|
+
lastCleared = visible.map(job => job.jobId);
|
|
566
|
+
for (const jobId of lastCleared)
|
|
567
|
+
cleared.add(jobId);
|
|
568
|
+
ctx.ui.notify("Cleared Braid progress. Run /braid:clear again to restore it.", "info");
|
|
569
|
+
}
|
|
570
|
+
else if (lastCleared.length) {
|
|
571
|
+
for (const jobId of lastCleared)
|
|
572
|
+
cleared.delete(jobId);
|
|
573
|
+
lastCleared = [];
|
|
574
|
+
ctx.ui.notify("Restored Braid progress.", "info");
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
ctx.ui.notify("No Braid progress to clear.", "info");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
refresh();
|
|
581
|
+
},
|
|
582
|
+
});
|
|
356
583
|
pi.on("session_start", (_event, ctx) => {
|
|
357
584
|
clear();
|
|
358
585
|
if (ctx.mode !== "tui")
|
|
359
586
|
return;
|
|
360
|
-
|
|
361
|
-
if (!
|
|
587
|
+
refresh = () => {
|
|
588
|
+
if (!shown().length) {
|
|
362
589
|
ctx.ui.setWidget("braid-status", undefined);
|
|
363
590
|
return;
|
|
364
591
|
}
|
|
365
592
|
ctx.ui.setWidget("braid-status", (_tui, theme) => ({
|
|
366
593
|
render(width) {
|
|
367
|
-
const all =
|
|
594
|
+
const all = shown();
|
|
368
595
|
const active = all.filter(job => job.status === "running");
|
|
369
596
|
const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
|
|
370
597
|
const lines = selected.map(job => {
|
|
@@ -393,7 +620,7 @@ export function registerBraidWidget(pi, jobs) {
|
|
|
393
620
|
invalidate() { },
|
|
394
621
|
}), { placement: "aboveEditor" });
|
|
395
622
|
};
|
|
396
|
-
const unsubscribe = jobs.subscribe(refresh);
|
|
623
|
+
const unsubscribe = jobs.subscribe(() => refresh());
|
|
397
624
|
dispose = () => {
|
|
398
625
|
unsubscribe();
|
|
399
626
|
ctx.ui.setWidget("braid-status", undefined);
|
|
@@ -416,7 +643,8 @@ export function registerBraidCommand(pi, jobs) {
|
|
|
416
643
|
let panel;
|
|
417
644
|
try {
|
|
418
645
|
await ctx.ui.custom((tui, theme, _keys, done) => {
|
|
419
|
-
|
|
646
|
+
// The assistant uses whichever model the main session has selected when asked.
|
|
647
|
+
panel = new BraidPanel(jobs, tui, theme, done, jobId, focus, () => ({ registry: ctx.modelRegistry, ...(ctx.model ? { model: ctx.model } : {}) }));
|
|
420
648
|
return panel;
|
|
421
649
|
}, {
|
|
422
650
|
overlay: true,
|
package/dist/display.js
CHANGED
|
@@ -301,6 +301,8 @@ function eventText(event) {
|
|
|
301
301
|
return `handoff · ${compact(event.from, 40)} → ${compact(event.to, 40)}: ${compact(event.output, 64)}`;
|
|
302
302
|
case "node_started":
|
|
303
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)}`;
|
|
304
306
|
case "node_completed":
|
|
305
307
|
return `completed · ${compact(event.nodeId, 40)}${event.decision ? ` → ${compact(event.decision, 30)}` : ""}: ${compact(event.output, 64)}`;
|
|
306
308
|
case "node_skipped":
|
|
@@ -329,7 +331,9 @@ function renderEventLog(events, expanded, theme) {
|
|
|
329
331
|
? "success"
|
|
330
332
|
: event.type === "node_started"
|
|
331
333
|
? "accent"
|
|
332
|
-
: "
|
|
334
|
+
: event.type === "human_input"
|
|
335
|
+
? "warning"
|
|
336
|
+
: "dim";
|
|
333
337
|
lines.push(theme.fg(color, ` ${String(event.sequence).padStart(3, "0")} ${eventText(event)}`));
|
|
334
338
|
}
|
|
335
339
|
return lines;
|
|
@@ -351,6 +355,8 @@ export function applyEvent(state, event) {
|
|
|
351
355
|
const preview = { ...event };
|
|
352
356
|
if ("output" in preview && preview.output !== undefined)
|
|
353
357
|
preview.output = compact(preview.output, 180);
|
|
358
|
+
if (preview.type === "human_input")
|
|
359
|
+
preview.text = compact(preview.text, 180);
|
|
354
360
|
if ("error" in preview)
|
|
355
361
|
preview.error = {
|
|
356
362
|
...preview.error,
|
|
@@ -463,6 +469,9 @@ export function applyEvent(state, event) {
|
|
|
463
469
|
node.skipReason = event.reason;
|
|
464
470
|
node.finishedAt = event.timestamp;
|
|
465
471
|
break;
|
|
472
|
+
case "human_input":
|
|
473
|
+
node.humanInputs = [...(node.humanInputs ?? []), { text: compact(event.text, 180), timestamp: event.timestamp }];
|
|
474
|
+
break;
|
|
466
475
|
}
|
|
467
476
|
}
|
|
468
477
|
export function applyProgress(state, progress) {
|
|
@@ -504,7 +513,7 @@ export function renderActivity(activity, expanded, theme, now, part = "all") {
|
|
|
504
513
|
const hidden = activity.droppedEntries + activity.entries.length - visible.length;
|
|
505
514
|
lines.push(theme.fg("dim", `history · ${activity.sequence} events${hidden ? ` · ${hidden} earlier ${expanded ? "trimmed" : "hidden (expand for more)"}` : ""}:`));
|
|
506
515
|
for (const entry of visible) {
|
|
507
|
-
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : "muted";
|
|
516
|
+
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : entry.kind === "human_input" ? "warning" : "muted";
|
|
508
517
|
lines.push(theme.fg(color, ` ${`+${elapsed(Math.max(0, entry.timestamp - activity.startedAt))}`.padStart(7)} ${compact(entry.summary, 200)}`));
|
|
509
518
|
// Show a detail preview only when the summary line does not already contain it.
|
|
510
519
|
if (expanded && entry.detail && entry.kind !== "workspace" && !compact(entry.summary, 400).includes(compact(entry.detail, 400))) {
|
|
@@ -561,10 +570,12 @@ export function renderSessionHeader(node, theme, now, progress, activity) {
|
|
|
561
570
|
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : node.status === "running" ? "▶" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
562
571
|
...(facts.length ? [theme.fg("muted", facts.join(" · "))] : []),
|
|
563
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))] : []),
|
|
564
574
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, false, theme, now, "summary") : []),
|
|
565
575
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${compact(node.error.message, 300)}`)] : []),
|
|
566
576
|
];
|
|
567
577
|
}
|
|
578
|
+
const humanInputNotice = (count) => `✎ received ${count} human message${count === 1 ? "" : "s"} while running; its result is not from the graph alone`;
|
|
568
579
|
/** What a node view shows below its pinned header when no live session is retained. */
|
|
569
580
|
export function renderNodeDetails(node, theme, now, activity) {
|
|
570
581
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
@@ -575,6 +586,10 @@ export function renderNodeDetails(node, theme, now, activity) {
|
|
|
575
586
|
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
576
587
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
577
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
|
+
] : []),
|
|
578
593
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, true, theme, now, "history") : []),
|
|
579
594
|
...(node.output ? ["", ...clean(node.output).split("\n")] : []),
|
|
580
595
|
];
|
|
@@ -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/jobs.js
CHANGED
|
@@ -7,11 +7,15 @@ import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/bra
|
|
|
7
7
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
8
8
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
9
9
|
import { PiTranscripts } from "./transcript.js";
|
|
10
|
+
import { PiHumanInputs } from "./human-input.js";
|
|
11
|
+
import { BraidAssistant } from "./assistant.js";
|
|
10
12
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
11
13
|
export class BraidJobs {
|
|
12
14
|
onFinished;
|
|
13
15
|
onNodeFinished;
|
|
14
16
|
jobs = new Map();
|
|
17
|
+
assistants = new Map();
|
|
18
|
+
cwd = process.cwd();
|
|
15
19
|
handles = new Map();
|
|
16
20
|
nextHandle = 1;
|
|
17
21
|
listeners = new Set();
|
|
@@ -47,6 +51,7 @@ export class BraidJobs {
|
|
|
47
51
|
: undefined;
|
|
48
52
|
const registry = ctx.modelRegistry;
|
|
49
53
|
const cwd = ctx.cwd;
|
|
54
|
+
this.cwd = cwd;
|
|
50
55
|
const job = {
|
|
51
56
|
handle: `job-${this.nextHandle++}`,
|
|
52
57
|
jobId: crypto.randomUUID(),
|
|
@@ -60,6 +65,7 @@ export class BraidJobs {
|
|
|
60
65
|
runningExecutions: new Map(),
|
|
61
66
|
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
62
67
|
transcripts: new PiTranscripts(() => this.changed()),
|
|
68
|
+
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
63
69
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
64
70
|
};
|
|
65
71
|
this.jobs.set(job.jobId, job);
|
|
@@ -84,6 +90,7 @@ export class BraidJobs {
|
|
|
84
90
|
cwd,
|
|
85
91
|
activity: job.activity,
|
|
86
92
|
transcripts: job.transcripts,
|
|
93
|
+
humanInputs: job.humanInputs,
|
|
87
94
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
88
95
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
89
96
|
onUsage: (usage) => {
|
|
@@ -104,6 +111,10 @@ export class BraidJobs {
|
|
|
104
111
|
}
|
|
105
112
|
finally {
|
|
106
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
|
+
}
|
|
107
118
|
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
108
119
|
}
|
|
109
120
|
};
|
|
@@ -213,7 +224,7 @@ export class BraidJobs {
|
|
|
213
224
|
const job = this.lookup(jobId);
|
|
214
225
|
if (!job)
|
|
215
226
|
return undefined;
|
|
216
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, ...snapshot } = job;
|
|
227
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
217
228
|
const copy = structuredClone(snapshot);
|
|
218
229
|
if (job.run)
|
|
219
230
|
copy.execution = job.run.snapshot();
|
|
@@ -249,6 +260,39 @@ export class BraidJobs {
|
|
|
249
260
|
throw this.unknownJob(jobId);
|
|
250
261
|
return job.transcripts.get(executionId);
|
|
251
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
|
+
}
|
|
252
296
|
update(jobId, patch) {
|
|
253
297
|
const job = this.lookup(jobId);
|
|
254
298
|
if (!job)
|
|
@@ -321,6 +365,8 @@ export class BraidJobs {
|
|
|
321
365
|
this.disposed = true;
|
|
322
366
|
for (const job of this.jobs.values())
|
|
323
367
|
job.controller.abort();
|
|
368
|
+
for (const assistant of this.assistants.values())
|
|
369
|
+
assistant.dispose();
|
|
324
370
|
this.listeners.clear();
|
|
325
371
|
}
|
|
326
372
|
}
|
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,7 @@ 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";
|
|
7
8
|
/** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
|
|
8
9
|
export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
|
|
9
10
|
const options = typeof onUsageOrOptions === "function"
|
|
@@ -150,6 +151,16 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
150
151
|
predecessors: request.predecessors.length,
|
|
151
152
|
workspace: `${workspace.mode} · ${workingDirectory}`,
|
|
152
153
|
}, toolByName);
|
|
154
|
+
const humanInputs = executionId === undefined ? undefined : options.humanInputs;
|
|
155
|
+
// Between model requests only: a running tool batch is never interrupted.
|
|
156
|
+
const deliverHumanInputs = () => {
|
|
157
|
+
const inputs = humanInputs?.take(executionId) ?? [];
|
|
158
|
+
for (const input of inputs) {
|
|
159
|
+
request.recordHumanInput?.(input.text);
|
|
160
|
+
context.messages.push({ role: "user", content: formatHumanInput(input.text), timestamp: Date.now() });
|
|
161
|
+
}
|
|
162
|
+
transcripts?.human(executionId, inputs.map(input => input.text), true);
|
|
163
|
+
};
|
|
153
164
|
const systemPrompt = context.systemPrompt;
|
|
154
165
|
const sessionId = crypto.randomUUID();
|
|
155
166
|
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -161,6 +172,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
161
172
|
let reportedContextTokens;
|
|
162
173
|
const complete = async () => {
|
|
163
174
|
request.signal.throwIfAborted();
|
|
175
|
+
deliverHumanInputs();
|
|
164
176
|
const budgets = [];
|
|
165
177
|
if (Number.isFinite(maxToolRounds)) {
|
|
166
178
|
budgets.push(`Tool round budget: ${toolRounds}/${maxToolRounds} used; ${maxToolRounds - toolRounds} remaining. A round is one assistant response containing tool calls.`);
|
|
@@ -336,8 +348,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
336
348
|
.map((block) => block.text));
|
|
337
349
|
const calls = response.content.filter((block) => block.type === "toolCall");
|
|
338
350
|
if (calls.length === 0) {
|
|
339
|
-
if (response.stopReason === "stop")
|
|
340
|
-
|
|
351
|
+
if (response.stopReason === "stop") {
|
|
352
|
+
// A message that arrived during the final request gets one more round.
|
|
353
|
+
if (!humanInputs?.pending(executionId).length)
|
|
354
|
+
break;
|
|
355
|
+
context.messages.push(response);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
341
358
|
throw new Error("Pi model requested tool use without a tool call");
|
|
342
359
|
}
|
|
343
360
|
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 {
|
package/dist/transcript.js
CHANGED
|
@@ -45,6 +45,20 @@ export class PiTranscripts {
|
|
|
45
45
|
transcript.messages.push(result);
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
/** Record human messages: delivered before a model request, or left over when the execution ended. */
|
|
49
|
+
human(executionId, texts, delivered) {
|
|
50
|
+
if (!texts.length)
|
|
51
|
+
return;
|
|
52
|
+
this.update(executionId, transcript => {
|
|
53
|
+
for (const text of texts)
|
|
54
|
+
transcript.messages.push({ role: "human", text, timestamp: Date.now(), delivered });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Accepting messages: the execution's session has started and has not finished. */
|
|
58
|
+
isLive(executionId) {
|
|
59
|
+
const transcript = this.transcripts.get(executionId);
|
|
60
|
+
return transcript !== undefined && !transcript.finished;
|
|
61
|
+
}
|
|
48
62
|
/** Close interrupted streams and tools so the view does not show them as still running. */
|
|
49
63
|
finish(executionId, reason = "Execution ended before this step finished") {
|
|
50
64
|
if (executionId === undefined)
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrok/pi-braid",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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.
|
|
9
|
+
"@chrok/braid": "0.3.4"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"@earendil-works/pi-ai": "*",
|