@chrok/pi-braid 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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"
@@ -41,6 +42,9 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
41
42
  const model = registry.find(name.slice(0, slash), name.slice(slash + 1));
42
43
  if (!model)
43
44
  throw new Error(`Pi model '${name}' is not registered; use an exact provider/modelId from /model`);
45
+ const activity = options.activity?.recorder(request);
46
+ if (!registry.stream)
47
+ activity?.limitation("This Pi model registry does not stream; model activity is observed only when a request starts and finishes.");
44
48
  const workspace = request.workspace ?? {
45
49
  nodeId: request.node.id, mode: "read-only", workingDirectory: sourceDirectory, state: "ready",
46
50
  };
@@ -139,6 +143,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
139
143
  tools: allToolDefinitions,
140
144
  };
141
145
  const toolByName = new Map(fileTools.map((tool) => [tool.name, tool]));
146
+ const executionId = request.execution.executionId;
147
+ const transcripts = executionId === undefined ? undefined : options.transcripts;
148
+ transcripts?.start(executionId, request.node.id, workingDirectory, {
149
+ prompt: request.node.prompt ?? `Default ${request.node.type} instructions`,
150
+ goal: request.goal,
151
+ predecessors: request.predecessors.length,
152
+ workspace: `${workspace.mode} · ${workingDirectory}`,
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
+ };
142
164
  const systemPrompt = context.systemPrompt;
143
165
  const sessionId = crypto.randomUUID();
144
166
  const usage = { inputTokens: 0, outputTokens: 0 };
@@ -150,6 +172,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
150
172
  let reportedContextTokens;
151
173
  const complete = async () => {
152
174
  request.signal.throwIfAborted();
175
+ deliverHumanInputs();
153
176
  const budgets = [];
154
177
  if (Number.isFinite(maxToolRounds)) {
155
178
  budgets.push(`Tool round budget: ${toolRounds}/${maxToolRounds} used; ${maxToolRounds - toolRounds} remaining. A round is one assistant response containing tool calls.`);
@@ -171,14 +194,54 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
171
194
  toolRounds,
172
195
  phase: "model",
173
196
  });
174
- // Registry.complete resolves stored API keys, OAuth, custom headers and endpoints.
175
- const response = await registry.complete(model, context, {
197
+ // The registry resolves stored API keys, OAuth, custom headers and endpoints.
198
+ // complete() is stream().result(); streaming only adds observation.
199
+ const requestOptions = {
176
200
  signal: request.signal,
177
201
  sessionId,
178
202
  cacheRetention: "none",
179
203
  transport: "sse",
180
204
  maxRetries: 0,
205
+ };
206
+ activity?.modelRequest();
207
+ let response;
208
+ try {
209
+ if (registry.stream) {
210
+ const stream = registry.stream(model, context, requestOptions);
211
+ for await (const event of stream) {
212
+ if (event.type !== "done" && event.type !== "error")
213
+ transcripts?.streaming(executionId, event.partial);
214
+ if (event.type === "text_delta")
215
+ activity?.modelStream("text", event.delta);
216
+ else if (event.type === "thinking_delta")
217
+ activity?.modelStream("reasoning", event.delta);
218
+ else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
219
+ const block = event.partial.content[event.contentIndex];
220
+ activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
221
+ }
222
+ }
223
+ response = await stream.result();
224
+ }
225
+ else {
226
+ response = await registry.complete(model, context, requestOptions);
227
+ }
228
+ }
229
+ catch (error) {
230
+ activity?.modelError(error instanceof Error ? error.message : String(error));
231
+ throw error;
232
+ }
233
+ transcripts?.assistant(executionId, response);
234
+ activity?.modelResponse({
235
+ text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
236
+ reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
237
+ toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
238
+ stopReason: response.stopReason,
239
+ inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
240
+ outputTokens: response.usage.output,
181
241
  });
242
+ if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
243
+ activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
244
+ }
182
245
  actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
183
246
  const providerContextTokens = response.usage.input +
184
247
  response.usage.cacheRead +
@@ -234,7 +297,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
234
297
  return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
235
298
  }
236
299
  const args = validateToolCall(allToolDefinitions, call);
237
- const execute = () => tool.execute(call.id, args, request.signal, undefined);
300
+ const execute = () => tool.execute(call.id, args, request.signal,
301
+ // Bash sends cumulative, throttled output snapshots.
302
+ (partial) => {
303
+ activity?.toolOutput(call.id, partial.content
304
+ .flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
305
+ transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
306
+ });
238
307
  const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
239
308
  ? await request.withWorkspaceWrite(execute)
240
309
  : await execute();
@@ -254,6 +323,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
254
323
  return toolResult(call, error instanceof Error ? error.message : String(error), true);
255
324
  }
256
325
  };
326
+ const executeCall = async (call) => {
327
+ if (call.name !== "decide")
328
+ return executeFileTool(call);
329
+ if (request.node.type !== "decision") {
330
+ return toolResult(call, "decide is available only on decision nodes", true);
331
+ }
332
+ const args = call.arguments;
333
+ if (!args ||
334
+ Array.isArray(args) ||
335
+ Object.keys(args).length !== 1 ||
336
+ typeof args.choice !== "string") {
337
+ throw new Error("decide requires exactly one string argument: choice");
338
+ }
339
+ // No coercion or prose parsing: the core enforces the enum and exactly one call.
340
+ request.decide(args.choice);
341
+ decided = true;
342
+ return toolResult(call, JSON.stringify({ choice: args.choice }), false);
343
+ };
257
344
  while (true) {
258
345
  const response = await complete();
259
346
  textParts.push(...response.content
@@ -261,8 +348,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
261
348
  .map((block) => block.text));
262
349
  const calls = response.content.filter((block) => block.type === "toolCall");
263
350
  if (calls.length === 0) {
264
- if (response.stopReason === "stop")
265
- break;
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
+ }
266
358
  throw new Error("Pi model requested tool use without a tool call");
267
359
  }
268
360
  if (response.stopReason !== "toolUse") {
@@ -287,26 +379,20 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
287
379
  const results = [];
288
380
  for (const call of calls) {
289
381
  request.signal.throwIfAborted();
290
- if (call.name === "decide") {
291
- if (request.node.type !== "decision") {
292
- results.push(toolResult(call, "decide is available only on decision nodes", true));
293
- continue;
294
- }
295
- const args = call.arguments;
296
- if (!args ||
297
- Array.isArray(args) ||
298
- Object.keys(args).length !== 1 ||
299
- typeof args.choice !== "string") {
300
- throw new Error("decide requires exactly one string argument: choice");
301
- }
302
- // No coercion or prose parsing: the core enforces the enum and exactly one call.
303
- request.decide(args.choice);
304
- decided = true;
305
- results.push(toolResult(call, JSON.stringify({ choice: args.choice }), false));
382
+ activity?.toolStart(call.id, call.name, call.arguments);
383
+ transcripts?.toolStart(executionId, call.id);
384
+ let result;
385
+ try {
386
+ result = await executeCall(call);
306
387
  }
307
- else {
308
- results.push(await executeFileTool(call));
388
+ catch (error) {
389
+ activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
390
+ throw error;
309
391
  }
392
+ activity?.toolEnd(call.id, result.content
393
+ .flatMap(block => block.type === "text" ? [block.text] : []).join("\n"), result.isError);
394
+ transcripts?.toolResult(executionId, result);
395
+ results.push(result);
310
396
  }
311
397
  context.messages.push(...results);
312
398
  if (decided) {
@@ -0,0 +1,138 @@
1
+ import { AssistantMessageComponent, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Spacer, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
3
+ const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
4
+ /**
5
+ * Renders a worker conversation with Pi's own chat components, applying only
6
+ * what changed since the last sync, the way Pi's interactive mode streams.
7
+ */
8
+ export class NodeSessionView {
9
+ tui;
10
+ theme;
11
+ chat = new Container();
12
+ transcript;
13
+ version = -1;
14
+ rendered = 0;
15
+ streaming;
16
+ tools = new Map();
17
+ partials = new Map();
18
+ expanded = false;
19
+ constructor(tui, theme) {
20
+ this.tui = tui;
21
+ this.theme = theme;
22
+ }
23
+ /** Tool output expansion, like Pi's ctrl+o. */
24
+ toggleExpanded() {
25
+ this.expanded = !this.expanded;
26
+ for (const tool of this.tools.values())
27
+ tool.setExpanded(this.expanded);
28
+ this.version = -1;
29
+ }
30
+ render(transcript, width) {
31
+ this.sync(transcript);
32
+ // Pi marks its own prompts for terminal shell integration; these are not prompts.
33
+ return this.chat.render(width).map(line => line.replace(/\x1b\]133;[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""));
34
+ }
35
+ sync(transcript) {
36
+ if (transcript !== this.transcript)
37
+ this.reset(transcript);
38
+ if (transcript.version === this.version)
39
+ return;
40
+ this.version = transcript.version;
41
+ for (; this.rendered < transcript.messages.length; this.rendered++) {
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
+ }
58
+ if (message.role === "toolResult") {
59
+ this.tools.get(message.toolCallId)?.updateResult(message);
60
+ this.partials.delete(message.toolCallId);
61
+ continue;
62
+ }
63
+ // The final message replaces the streamed partial in the same component.
64
+ const component = this.streaming ?? this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
65
+ this.streaming = undefined;
66
+ component.updateContent(message, false);
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();
71
+ }
72
+ if (transcript.streaming) {
73
+ this.streaming ??= this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
74
+ this.streaming.updateContent(transcript.streaming, true);
75
+ this.toolCalls(transcript.streaming, transcript, false);
76
+ }
77
+ for (const [callId, partial] of transcript.running) {
78
+ const tool = this.tools.get(callId);
79
+ if (!tool || this.partials.get(callId) === partial)
80
+ continue;
81
+ this.partials.set(callId, partial);
82
+ tool.markExecutionStarted();
83
+ if (partial)
84
+ tool.updateResult({ ...partial, isError: false }, true);
85
+ }
86
+ }
87
+ toolCalls(message, transcript, complete) {
88
+ for (const block of message.content) {
89
+ if (block.type !== "toolCall")
90
+ continue;
91
+ let tool = this.tools.get(block.id);
92
+ if (!tool) {
93
+ tool = new ToolExecutionComponent(block.name, block.id, block.arguments, undefined, transcript.renderers.get(block.name), this.tui, transcript.cwd);
94
+ tool.setExpanded(this.expanded);
95
+ this.tools.set(block.id, tool);
96
+ this.add(tool);
97
+ }
98
+ else if (!complete)
99
+ tool.updateArgs(block.arguments);
100
+ if (complete) {
101
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
102
+ tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Error" }], isError: true });
103
+ }
104
+ else {
105
+ tool.updateArgs(block.arguments);
106
+ tool.setArgsComplete();
107
+ }
108
+ }
109
+ }
110
+ }
111
+ reset(transcript) {
112
+ this.transcript = transcript;
113
+ this.chat.clear();
114
+ this.tools.clear();
115
+ this.partials.clear();
116
+ this.streaming = undefined;
117
+ this.rendered = 0;
118
+ this.version = -1;
119
+ const intro = transcript.intro;
120
+ if (!intro)
121
+ return;
122
+ this.add(new UserMessageComponent(intro.prompt, getMarkdownTheme()));
123
+ this.add(new Text(this.theme.fg("dim", [
124
+ `goal: ${plain(intro.goal)}`,
125
+ `context: ${intro.predecessors} predecessor output${intro.predecessors === 1 ? "" : "s"} · workspace ${plain(intro.workspace)}`,
126
+ ].join("\n")), 1, 0));
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
+ }
134
+ add(component) {
135
+ this.chat.addChild(component);
136
+ return component;
137
+ }
138
+ }
@@ -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((resolve, reject) => {
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
- if (error && error.code !== 128)
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 {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Live worker conversations keyed by execution ID. Running executions and the
3
+ * newest finished ones are retained; older transcripts are dropped to bound memory.
4
+ */
5
+ export class PiTranscripts {
6
+ onChange;
7
+ maxFinished;
8
+ transcripts = new Map();
9
+ finishedIds = [];
10
+ pending;
11
+ constructor(onChange = () => { }, maxFinished = 20) {
12
+ this.onChange = onChange;
13
+ this.maxFinished = maxFinished;
14
+ }
15
+ get(executionId) {
16
+ return this.transcripts.get(executionId);
17
+ }
18
+ start(executionId, nodeId, cwd, intro, renderers) {
19
+ this.transcripts.set(executionId, {
20
+ executionId, nodeId, cwd, intro, messages: [], running: new Map(), renderers, version: 0, finished: false,
21
+ });
22
+ this.changed();
23
+ }
24
+ streaming(executionId, partial) {
25
+ this.update(executionId, transcript => { transcript.streaming = partial; }, true);
26
+ }
27
+ assistant(executionId, message) {
28
+ this.update(executionId, transcript => {
29
+ delete transcript.streaming;
30
+ transcript.messages.push(message);
31
+ });
32
+ }
33
+ toolStart(executionId, callId) {
34
+ this.update(executionId, transcript => { transcript.running.set(callId, undefined); });
35
+ }
36
+ toolUpdate(executionId, callId, partial) {
37
+ this.update(executionId, transcript => {
38
+ if (transcript.running.has(callId))
39
+ transcript.running.set(callId, partial);
40
+ }, true);
41
+ }
42
+ toolResult(executionId, result) {
43
+ this.update(executionId, transcript => {
44
+ transcript.running.delete(result.toolCallId);
45
+ transcript.messages.push(result);
46
+ });
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
+ }
62
+ /** Close interrupted streams and tools so the view does not show them as still running. */
63
+ finish(executionId, reason = "Execution ended before this step finished") {
64
+ if (executionId === undefined)
65
+ return;
66
+ this.update(executionId, transcript => {
67
+ if (transcript.streaming) {
68
+ transcript.messages.push({ ...transcript.streaming, stopReason: "aborted", errorMessage: reason });
69
+ delete transcript.streaming;
70
+ }
71
+ for (const callId of transcript.running.keys()) {
72
+ const call = transcript.messages.flatMap(message => message.role === "assistant" ? message.content : [])
73
+ .find(block => block.type === "toolCall" && block.id === callId);
74
+ transcript.messages.push({
75
+ role: "toolResult", toolCallId: callId, toolName: call?.type === "toolCall" ? call.name : "tool",
76
+ content: [{ type: "text", text: reason }], isError: true, timestamp: Date.now(),
77
+ });
78
+ }
79
+ transcript.running.clear();
80
+ transcript.finished = true;
81
+ if (!this.finishedIds.includes(executionId))
82
+ this.finishedIds.push(executionId);
83
+ while (this.finishedIds.length > this.maxFinished)
84
+ this.transcripts.delete(this.finishedIds.shift());
85
+ });
86
+ }
87
+ /** Token and output bursts coalesce into at most one notification per 100 ms. */
88
+ update(executionId, change, burst = false) {
89
+ const transcript = this.transcripts.get(executionId);
90
+ if (!transcript || transcript.finished)
91
+ return;
92
+ try {
93
+ change(transcript);
94
+ transcript.version++;
95
+ }
96
+ catch {
97
+ // Display state must never affect node execution.
98
+ }
99
+ if (!burst)
100
+ this.changed();
101
+ else if (!this.pending) {
102
+ this.pending = setTimeout(() => this.changed(), 100);
103
+ this.pending.unref?.();
104
+ }
105
+ }
106
+ changed() {
107
+ clearTimeout(this.pending);
108
+ this.pending = undefined;
109
+ try {
110
+ this.onChange();
111
+ }
112
+ catch { /* Observers cannot fail a worker. */ }
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.2",
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.2"
9
+ "@chrok/braid": "0.3.4"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",