cookbook-bridge 0.1.4 → 0.1.6
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/bridge.mjs +25 -4
- package/codex-runner.mjs +8 -4
- package/live.mjs +173 -0
- package/package.json +2 -1
- package/thread-runner.mjs +10 -5
package/bridge.mjs
CHANGED
|
@@ -48,12 +48,14 @@ let createLocalServer, toolsForMode, modeForTools, vendorOf;
|
|
|
48
48
|
let connectAgentsProgrammatic, detectClis;
|
|
49
49
|
let serveCalls, describeCall, hostingMode;
|
|
50
50
|
let fetchHands, claimHandsCall, reportHandsResult;
|
|
51
|
+
let callsFromStreamLine, foldCallEvent, wireCalls;
|
|
51
52
|
|
|
52
53
|
async function loadRuntime() {
|
|
53
54
|
({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
|
|
54
55
|
({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
|
|
55
56
|
({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
|
|
56
57
|
({ serveCalls, describeCall, hostingMode } = await import("./hands.mjs"));
|
|
58
|
+
({ callsFromStreamLine, foldCallEvent, wireCalls } = await import("./live.mjs"));
|
|
57
59
|
({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand } = await import("./harden.mjs"));
|
|
58
60
|
({ extractUsage, displayText } = await import("./usage.mjs"));
|
|
59
61
|
({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
|
|
@@ -229,6 +231,19 @@ export function allowedByPolicy(cfg, agent, task) {
|
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
|
|
234
|
+
/**
|
|
235
|
+
* IDENTITY PINNING for the persistent runner. spawnAgent rewrites a claude command
|
|
236
|
+
* with --strict-mcp-config + the agent's own Cookbook token; the thread runner
|
|
237
|
+
* builds its argv from agent.command directly, so without this an agent with its
|
|
238
|
+
* own token (Chef) ran as whoever the machine's Claude was logged in as — seen
|
|
239
|
+
* 2026-08-28: Chef saw diego's workspaces and "No such grant". Same rewrite, once.
|
|
240
|
+
*/
|
|
241
|
+
function pinnedAgent(cfg, agent) {
|
|
242
|
+
if (!agent || !agent.token || !Array.isArray(agent.command)) return agent;
|
|
243
|
+
const { command } = withCookbookMcp(agent.command, { token: agent.token, cookbookUrl: agent.cookbookUrl ?? cfg.cookbookUrl });
|
|
244
|
+
return command === agent.command ? agent : { ...agent, command };
|
|
245
|
+
}
|
|
246
|
+
|
|
232
247
|
/** Spawn the agent's headless CLI with the prompt substituted into its argv.
|
|
233
248
|
* `env` (from agentEnv) strips vendor API-billing keys unless the user opted in —
|
|
234
249
|
* a task must never silently bill an API account instead of the owner's subscription. */
|
|
@@ -370,6 +385,8 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
370
385
|
// deltas. A completed turn REPLACES its partials (same text arrives both ways).
|
|
371
386
|
let turnsText = "";
|
|
372
387
|
let partialText = "";
|
|
388
|
+
// Live CALLS (show the work): tool_use/tool_result folded into a capped list.
|
|
389
|
+
let calls = [];
|
|
373
390
|
const liveText = () => {
|
|
374
391
|
const full = partialText ? `${turnsText}${turnsText ? "\n\n" : ""}${partialText}` : turnsText;
|
|
375
392
|
return full.length > LIVE_TEXT_CAP ? "…" + full.slice(-LIVE_TEXT_CAP) : full;
|
|
@@ -383,7 +400,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
383
400
|
// Bridge restarts (local retryCtx state is trimmed; the task row isn't).
|
|
384
401
|
// Progress needs a token field to pass the server's substance check, so a
|
|
385
402
|
// text-only tick sends output_tokens as-is (0 is fine once input>0 arrives).
|
|
386
|
-
try { onProgress({ ...acc, runner: agent.name, ...(text ? { live_text: text } : {}), ...(sessionId ? { session_ref: sessionId } : {}) }); } catch { /* progress is best-effort */ }
|
|
403
|
+
try { onProgress({ ...acc, runner: agent.name, ...(text ? { live_text: text } : {}), ...(calls.length ? { live_calls: wireCalls(calls) } : {}), ...(sessionId ? { session_ref: sessionId } : {}) }); } catch { /* progress is best-effort */ }
|
|
387
404
|
};
|
|
388
405
|
|
|
389
406
|
let sessionId = null;
|
|
@@ -407,10 +424,14 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
407
424
|
partialText = "";
|
|
408
425
|
}
|
|
409
426
|
}
|
|
427
|
+
// A tool call is a discrete event people are watching for — it jumps the
|
|
428
|
+
// text throttle (still ≥300ms apart so a burst of reads is one tick).
|
|
429
|
+
let touched = false;
|
|
430
|
+
for (const ev of callsFromStreamLine(line)) { calls = foldCallEvent(calls, ev); touched = true; }
|
|
410
431
|
const r = foldStreamLine(line, acc);
|
|
411
432
|
acc = r.acc;
|
|
412
433
|
if (r.resultLine) resultLine = r.resultLine;
|
|
413
|
-
else if (Date.now() - lastEmit > (agent.progressThrottleMs ?? 1200)) emit();
|
|
434
|
+
else if (Date.now() - lastEmit > (touched ? 300 : (agent.progressThrottleMs ?? 1200))) emit();
|
|
414
435
|
}
|
|
415
436
|
});
|
|
416
437
|
child.stderr.on("data", (d) => { lastActivityAt = Date.now(); err += d; });
|
|
@@ -980,7 +1001,7 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
980
1001
|
const live = cfg.liveTokens !== false && agent.liveTokens !== false;
|
|
981
1002
|
const r = warmRunner ?? runnerFor({
|
|
982
1003
|
threadId: threadKey,
|
|
983
|
-
agent,
|
|
1004
|
+
agent: pinnedAgent(cfg, agent),
|
|
984
1005
|
env: agentEnv(cfg).env,
|
|
985
1006
|
resumeSessionId: canResumeThread ? threadSession : null,
|
|
986
1007
|
helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
|
|
@@ -1359,7 +1380,7 @@ async function dispatchWorkInner(cfg, work, warmHints) {
|
|
|
1359
1380
|
if (!agent || agent.runner === "app-server" || agent.runner === "robot") continue;
|
|
1360
1381
|
warmUp({
|
|
1361
1382
|
poolKey: `warm::${h.workspace_id}::${agent.name}`,
|
|
1362
|
-
agent,
|
|
1383
|
+
agent: pinnedAgent(cfg, agent),
|
|
1363
1384
|
env: agentEnv(cfg).env,
|
|
1364
1385
|
helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
|
|
1365
1386
|
log,
|
package/codex-runner.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import os from "node:os";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
|
+
import { codexCallEvent, foldCallEvent, wireCalls } from "./live.mjs";
|
|
26
27
|
|
|
27
28
|
const IDLE_MS = 15 * 60_000;
|
|
28
29
|
const LIVE_TEXT_CAP = 1800;
|
|
@@ -150,6 +151,8 @@ class CodexServer {
|
|
|
150
151
|
t.text += m.params.delta;
|
|
151
152
|
t.emit();
|
|
152
153
|
}
|
|
154
|
+
const callEv = codexCallEvent(meth, m.params);
|
|
155
|
+
if (callEv) { t.calls = foldCallEvent(t.calls, callEv); t.emit(true); }
|
|
153
156
|
if (m.params) {
|
|
154
157
|
const u = m.params.usage ?? m.params.tokenUsage ?? m.params.token_usage ?? (m.params.turn && m.params.turn.usage);
|
|
155
158
|
if (u && typeof u === "object") t.usage = u;
|
|
@@ -189,13 +192,14 @@ class CodexServer {
|
|
|
189
192
|
const startedAt = Date.now();
|
|
190
193
|
const t = {
|
|
191
194
|
resolve, reject,
|
|
192
|
-
text: "", usage: null,
|
|
195
|
+
text: "", usage: null, calls: [],
|
|
193
196
|
lastEmit: 0, lastActivityAt: startedAt,
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
// `event` = a tool call started/finished: jumps the text throttle (≥300ms).
|
|
198
|
+
emit: (event = false) => {
|
|
199
|
+
if (!onProgress || Date.now() - t.lastEmit < (event ? 300 : 1200)) return;
|
|
196
200
|
t.lastEmit = Date.now();
|
|
197
201
|
const tail = t.text.length > LIVE_TEXT_CAP ? "…" + t.text.slice(-LIVE_TEXT_CAP) : t.text;
|
|
198
|
-
try { onProgress({ input_tokens: 0, output_tokens: 0, runner: this.agent.name, ...(tail ? { live_text: tail } : {}) }); } catch { /* best-effort */ }
|
|
202
|
+
try { onProgress({ input_tokens: 0, output_tokens: 0, runner: this.agent.name, ...(tail ? { live_text: tail } : {}), ...(t.calls.length ? { live_calls: wireCalls(t.calls) } : {}) }); } catch { /* best-effort */ }
|
|
199
203
|
},
|
|
200
204
|
watchdog: setInterval(() => {
|
|
201
205
|
if (Date.now() - startedAt < timeoutSeconds * 1000) return;
|
package/live.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE CALLS — "show the work, not just the words" (Diego, 2026-08-26).
|
|
3
|
+
*
|
|
4
|
+
* The Bridge already streams what an agent is SAYING (live_text). This streams what
|
|
5
|
+
* it is DOING: every tool call, as a short human line — `read_file notes/plan.md`,
|
|
6
|
+
* `bash npm test`, `search "canvas ics feed"` — with a running/ok/err state. The
|
|
7
|
+
* thread shows it as a work log, the stage shows the current line, the chat
|
|
8
|
+
* sidebar shows it under the conversation. Same progress tick, one more field.
|
|
9
|
+
*
|
|
10
|
+
* Pure parsers over the vendors' own streams (no network, no fs) so they're
|
|
11
|
+
* testable: claude stream-json (`tool_use` / `tool_result` blocks), gemini
|
|
12
|
+
* stream-json (`tool_use` / `tool_result` events), codex app-server item
|
|
13
|
+
* notifications (`item/started` / `item/completed`).
|
|
14
|
+
*
|
|
15
|
+
* Shape on the wire (progress.live_calls, ≤ LIVE_CALLS_CAP entries, oldest first):
|
|
16
|
+
* { n: "read_file", a: "notes/plan.md", s: "run" | "ok" | "err", at: <epoch ms> }
|
|
17
|
+
* The server re-validates every field (src/lib/workspaces/live-calls.ts).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const LIVE_CALLS_CAP = 12;
|
|
21
|
+
const NAME_CAP = 60;
|
|
22
|
+
const ARG_CAP = 120;
|
|
23
|
+
|
|
24
|
+
/** Claude Code's built-in tools → the verb a teammate would say. MCP tools keep their
|
|
25
|
+
* own name (`read_file`); other servers' tools are prefixed (`github:create_issue`). */
|
|
26
|
+
const BUILTIN = {
|
|
27
|
+
read: "read", edit: "edit", multiedit: "edit", write: "write", notebookedit: "edit",
|
|
28
|
+
bash: "bash", grep: "grep", glob: "glob", ls: "ls",
|
|
29
|
+
webfetch: "fetch", websearch: "search", task: "agent", todowrite: "todo",
|
|
30
|
+
// gemini-cli built-ins
|
|
31
|
+
read_file: "read", write_file: "write", replace: "edit", run_shell_command: "bash",
|
|
32
|
+
list_directory: "ls", search_file_content: "grep", glob_files: "glob", web_fetch: "fetch", google_web_search: "search",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Harness plumbing nobody wants in a work log (Claude Code loads deferred tool
|
|
36
|
+
* schemas through ToolSearch before the real call). */
|
|
37
|
+
const SKIP = new Set(["toolsearch"]);
|
|
38
|
+
|
|
39
|
+
export function shortTool(name) {
|
|
40
|
+
const raw = String(name ?? "").trim();
|
|
41
|
+
if (!raw) return "tool";
|
|
42
|
+
const m = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(raw);
|
|
43
|
+
if (m) {
|
|
44
|
+
const server = m[1].toLowerCase();
|
|
45
|
+
const tool = m[2];
|
|
46
|
+
return (server === "cookbook" ? tool : `${server}:${tool}`).slice(0, NAME_CAP);
|
|
47
|
+
}
|
|
48
|
+
const key = raw.toLowerCase();
|
|
49
|
+
if (BUILTIN[key]) return BUILTIN[key];
|
|
50
|
+
return raw.slice(0, NAME_CAP);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ARG_KEYS = [
|
|
54
|
+
"path", "file_path", "filePath", "notebook_path", "absolute_path", "dir_path", "directory",
|
|
55
|
+
"command", "cmd", "query", "pattern", "url", "title", "verb", "name", "folder", "from", "to", "src", "dest",
|
|
56
|
+
"description", "prompt",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** One short, safe argument for the line. Paths and commands are what people want to
|
|
60
|
+
* see; ids and prose are last resort. Whitespace collapsed, capped. */
|
|
61
|
+
export function argFor(input) {
|
|
62
|
+
if (input == null) return "";
|
|
63
|
+
if (typeof input === "string") return clip(input);
|
|
64
|
+
if (typeof input !== "object") return clip(String(input));
|
|
65
|
+
for (const k of ARG_KEYS) {
|
|
66
|
+
const v = input[k];
|
|
67
|
+
if (typeof v === "string" && v.trim()) return clip(v);
|
|
68
|
+
if (Array.isArray(v) && v.length && typeof v[0] === "string") return clip(v.slice(0, 3).join(", "));
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clip(s) {
|
|
74
|
+
const one = String(s).replace(/\s+/g, " ").trim();
|
|
75
|
+
return one.length > ARG_CAP ? one.slice(0, ARG_CAP - 1) + "…" : one;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pull tool events out of one stream-json line. Returns an array (a claude turn can
|
|
80
|
+
* carry several tool_use blocks; a user line several tool_results), empty when the
|
|
81
|
+
* line is prose/usage/init. Event: {kind:'call', id, name, arg} | {kind:'result', id, err}.
|
|
82
|
+
*/
|
|
83
|
+
export function callsFromStreamLine(line) {
|
|
84
|
+
let j;
|
|
85
|
+
try { j = JSON.parse(line); } catch { return []; }
|
|
86
|
+
if (!j || typeof j !== "object") return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
// claude stream-json: assistant turn with tool_use blocks / user turn with tool_result blocks
|
|
89
|
+
if ((j.type === "assistant" || j.type === "user") && Array.isArray(j.message?.content)) {
|
|
90
|
+
for (const b of j.message.content) {
|
|
91
|
+
if (!b || typeof b !== "object") continue;
|
|
92
|
+
if (b.type === "tool_use") {
|
|
93
|
+
if (SKIP.has(String(b.name ?? "").toLowerCase())) continue;
|
|
94
|
+
out.push({ kind: "call", id: String(b.id ?? ""), name: shortTool(b.name), arg: argFor(b.input) });
|
|
95
|
+
} else if (b.type === "tool_result") out.push({ kind: "result", id: String(b.tool_use_id ?? ""), err: b.is_error === true });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
// gemini stream-json: flat tool_use / tool_result events
|
|
100
|
+
if (j.type === "tool_use" && (j.tool_name || j.name)) {
|
|
101
|
+
out.push({ kind: "call", id: String(j.tool_id ?? j.id ?? ""), name: shortTool(j.tool_name ?? j.name), arg: argFor(j.parameters ?? j.input) });
|
|
102
|
+
} else if (j.type === "tool_result") {
|
|
103
|
+
const st = String(j.status ?? "").toLowerCase();
|
|
104
|
+
out.push({ kind: "result", id: String(j.tool_id ?? j.tool_use_id ?? ""), err: st === "error" || st === "failed" || j.is_error === true });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Codex app-server: `item/started` + `item/completed` notifications carry a typed
|
|
111
|
+
* item. Defensive about naming (camel/snake, slash/dot) — the protocol is young.
|
|
112
|
+
* Returns one event or null.
|
|
113
|
+
*/
|
|
114
|
+
export function codexCallEvent(method, params) {
|
|
115
|
+
const meth = String(method ?? "");
|
|
116
|
+
const started = /item[/.]started$/.test(meth);
|
|
117
|
+
const completed = /item[/.]completed$/.test(meth);
|
|
118
|
+
if (!started && !completed) return null;
|
|
119
|
+
const item = params?.item;
|
|
120
|
+
if (!item || typeof item !== "object") return null;
|
|
121
|
+
const type = String(item.type ?? item.item_type ?? "").replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
122
|
+
const id = String(item.id ?? "");
|
|
123
|
+
let name = null;
|
|
124
|
+
let arg = "";
|
|
125
|
+
if (type === "commandExecution") { name = "bash"; arg = argFor(item.command ?? item.cmd); }
|
|
126
|
+
else if (type === "fileChange") {
|
|
127
|
+
name = "edit";
|
|
128
|
+
const ch = Array.isArray(item.changes) ? item.changes : [];
|
|
129
|
+
arg = clip(ch.map((c) => c?.path).filter(Boolean).slice(0, 3).join(", "));
|
|
130
|
+
}
|
|
131
|
+
else if (type === "mcpToolCall") {
|
|
132
|
+
const server = String(item.server ?? "").toLowerCase();
|
|
133
|
+
const tool = String(item.tool ?? item.name ?? "tool");
|
|
134
|
+
name = server && server !== "cookbook" ? `${server}:${tool}`.slice(0, NAME_CAP) : tool.slice(0, NAME_CAP);
|
|
135
|
+
arg = argFor(item.arguments ?? item.input ?? item.params);
|
|
136
|
+
}
|
|
137
|
+
else if (type === "webSearch") { name = "search"; arg = argFor(item.query ?? item); }
|
|
138
|
+
else return null; // agentMessage, reasoning, etc. are not calls
|
|
139
|
+
if (started) return { kind: "call", id, name, arg };
|
|
140
|
+
const st = String(item.status ?? "").toLowerCase();
|
|
141
|
+
const err = st === "failed" || st === "error" || st === "declined" || (typeof item.exit_code === "number" && item.exit_code !== 0) || (typeof item.exitCode === "number" && item.exitCode !== 0);
|
|
142
|
+
return { kind: "result", id, err };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fold one event into the running list (pure; returns a new array). A result closes
|
|
147
|
+
* the matching call by id — or, when the vendor gave no id, the oldest still-running
|
|
148
|
+
* one. Capped to the newest LIVE_CALLS_CAP so the tick stays small.
|
|
149
|
+
*/
|
|
150
|
+
export function foldCallEvent(list, ev, now = Date.now()) {
|
|
151
|
+
const cur = Array.isArray(list) ? list : [];
|
|
152
|
+
if (!ev) return cur;
|
|
153
|
+
if (ev.kind === "call") {
|
|
154
|
+
const next = [...cur, { id: ev.id || "", n: ev.name, a: ev.arg || "", s: "run", at: now }];
|
|
155
|
+
return next.length > LIVE_CALLS_CAP ? next.slice(next.length - LIVE_CALLS_CAP) : next;
|
|
156
|
+
}
|
|
157
|
+
if (ev.kind === "result") {
|
|
158
|
+
// Close by id; fall back to the oldest running call ONLY for id-less vendors.
|
|
159
|
+
// A known-but-unmatched id (e.g. a skipped ToolSearch) must not close a peer.
|
|
160
|
+
let i = ev.id ? cur.findIndex((c) => c.id === ev.id && c.s === "run") : -1;
|
|
161
|
+
if (i < 0 && !ev.id) i = cur.findIndex((c) => c.s === "run");
|
|
162
|
+
if (i < 0) return cur;
|
|
163
|
+
const next = cur.slice();
|
|
164
|
+
next[i] = { ...next[i], s: ev.err ? "err" : "ok" };
|
|
165
|
+
return next;
|
|
166
|
+
}
|
|
167
|
+
return cur;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Wire shape: drop the vendor id, keep what the UI renders. */
|
|
171
|
+
export function wireCalls(list) {
|
|
172
|
+
return (Array.isArray(list) ? list : []).map((c) => ({ n: c.n, ...(c.a ? { a: c.a } : {}), s: c.s, at: c.at }));
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"device.mjs",
|
|
19
19
|
"hands.mjs",
|
|
20
20
|
"harden.mjs",
|
|
21
|
+
"live.mjs",
|
|
21
22
|
"local.mjs",
|
|
22
23
|
"openclaw-runner.mjs",
|
|
23
24
|
"prompt.mjs",
|
package/thread-runner.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* module must not import it back).
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
+
import { callsFromStreamLine, foldCallEvent, wireCalls } from "./live.mjs";
|
|
21
22
|
|
|
22
23
|
const IDLE_MS = 10 * 60_000;
|
|
23
24
|
const runners = new Map(); // threadRootId -> Runner
|
|
@@ -91,6 +92,8 @@ class Runner {
|
|
|
91
92
|
if (spoke.kind === "delta") t.partialText += spoke.text;
|
|
92
93
|
else { t.turnsText += (t.turnsText && spoke.text ? "\n\n" : "") + spoke.text; t.partialText = ""; }
|
|
93
94
|
}
|
|
95
|
+
let touched = false;
|
|
96
|
+
for (const ev of callsFromStreamLine(line)) { t.calls = foldCallEvent(t.calls, ev); touched = true; }
|
|
94
97
|
const r = this.helpers.fold(line, t.acc);
|
|
95
98
|
t.acc = r.acc;
|
|
96
99
|
if (r.resultLine) {
|
|
@@ -100,7 +103,7 @@ class Runner {
|
|
|
100
103
|
this.lastUsedAt = Date.now();
|
|
101
104
|
t.resolve({ code: 0, out: r.resultLine, err: "", sessionId: this.sessionId });
|
|
102
105
|
} else {
|
|
103
|
-
t.emit();
|
|
106
|
+
t.emit(touched);
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
}
|
|
@@ -118,15 +121,17 @@ class Runner {
|
|
|
118
121
|
resolve, reject,
|
|
119
122
|
acc: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, num_turns: 0 },
|
|
120
123
|
turnsText: "", partialText: "",
|
|
124
|
+
calls: [], // live CALLS (bridge/live.mjs): the work log
|
|
121
125
|
lastEmit: 0, lastActivityAt: startedAt,
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
// `event` = a tool call started/finished: jumps the text throttle (≥300ms).
|
|
127
|
+
emit: (event = false) => {
|
|
128
|
+
if (!onProgress || Date.now() - t.lastEmit < (event ? 300 : 1200)) return;
|
|
124
129
|
t.lastEmit = Date.now();
|
|
125
130
|
const full = t.partialText ? `${t.turnsText}${t.turnsText ? "\n\n" : ""}${t.partialText}` : t.turnsText;
|
|
126
131
|
const live_text = full.length > 1800 ? "…" + full.slice(-1800) : full;
|
|
127
|
-
if (t.acc.input_tokens === 0 && t.acc.output_tokens === 0 && !live_text) return;
|
|
132
|
+
if (t.acc.input_tokens === 0 && t.acc.output_tokens === 0 && !live_text && !t.calls.length) return;
|
|
128
133
|
try {
|
|
129
|
-
onProgress({ ...t.acc, runner: this.agent.name, ...(live_text ? { live_text } : {}), ...(this.sessionId ? { session_ref: this.sessionId } : {}) });
|
|
134
|
+
onProgress({ ...t.acc, runner: this.agent.name, ...(live_text ? { live_text } : {}), ...(t.calls.length ? { live_calls: wireCalls(t.calls) } : {}), ...(this.sessionId ? { session_ref: this.sessionId } : {}) });
|
|
130
135
|
} catch { /* best-effort */ }
|
|
131
136
|
},
|
|
132
137
|
watchdog: setInterval(() => {
|