cookbook-bridge 0.1.3 → 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 +58 -20
- package/codex-runner.mjs +8 -4
- package/cookbook.mjs +8 -1
- package/device.mjs +13 -0
- package/hands.mjs +35 -2
- package/harden.mjs +39 -0
- package/live.mjs +173 -0
- package/local.mjs +1 -1
- package/package.json +2 -1
- package/thread-runner.mjs +10 -5
package/bridge.mjs
CHANGED
|
@@ -36,8 +36,8 @@ import { spawn } from "node:child_process";
|
|
|
36
36
|
// repair itself — the update path depends ONLY on update.mjs (node built-ins only).
|
|
37
37
|
// The e2e that forced this: a stale install missing volunteer.mjs couldn't even reach
|
|
38
38
|
// the updater when these were static imports.
|
|
39
|
-
let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings;
|
|
40
|
-
let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION;
|
|
39
|
+
let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
|
|
40
|
+
let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand;
|
|
41
41
|
let extractUsage, displayText;
|
|
42
42
|
let volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities;
|
|
43
43
|
let buildPrompt, buildThreadFollowUpPrompt;
|
|
@@ -46,15 +46,17 @@ let hasCodexThread, reapCodexServer, killCodexServer;
|
|
|
46
46
|
let checkForUpdate, applyUpdate;
|
|
47
47
|
let createLocalServer, toolsForMode, modeForTools, vendorOf;
|
|
48
48
|
let connectAgentsProgrammatic, detectClis;
|
|
49
|
-
let serveCalls, describeCall;
|
|
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
|
-
({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult } = await import("./cookbook.mjs"));
|
|
56
|
-
({ serveCalls, describeCall } = await import("./hands.mjs"));
|
|
57
|
-
({
|
|
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"));
|
|
57
|
+
({ serveCalls, describeCall, hostingMode } = await import("./hands.mjs"));
|
|
58
|
+
({ callsFromStreamLine, foldCallEvent, wireCalls } = await import("./live.mjs"));
|
|
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"));
|
|
60
62
|
({ buildPrompt, buildThreadFollowUpPrompt } = await import("./prompt.mjs"));
|
|
@@ -164,7 +166,7 @@ function loadConfig() {
|
|
|
164
166
|
// browser, run granted verbs on this machine? Off unless explicitly enabled —
|
|
165
167
|
// `cookbook-bridge host` sets it. A Bridge that never hosts never even asks the
|
|
166
168
|
// server for calls, so this costs nothing when unused.
|
|
167
|
-
cfg.hosting = cfg.hosting ?? { enabled: false
|
|
169
|
+
cfg.hosting = cfg.hosting ?? {}; // enabled: true=always, false=off, absent=grants you approved (hands.mjs hostingMode)
|
|
168
170
|
cfg.maxAttempts = cfg.maxAttempts ?? 2;
|
|
169
171
|
// Phase 1 semantics: taskTimeoutSeconds is the ABSOLUTE CEILING (cost backstop),
|
|
170
172
|
// livenessTimeoutSeconds is the stall detector (no output for this long = dead).
|
|
@@ -176,6 +178,7 @@ function loadConfig() {
|
|
|
176
178
|
// execution let one long run block every workspace's queue).
|
|
177
179
|
cfg.maxConcurrentRuns = Math.max(1, cfg.maxConcurrentRuns ?? 2);
|
|
178
180
|
cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
|
|
181
|
+
for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
|
|
179
182
|
ensureAgentPath(); // so bare `claude`/`gemini` commands resolve under the app's minimal PATH
|
|
180
183
|
loadRunState(); // restore attempts/given-up so a restart can't grant doomed tasks fresh attempts
|
|
181
184
|
return cfg;
|
|
@@ -228,6 +231,19 @@ export function allowedByPolicy(cfg, agent, task) {
|
|
|
228
231
|
}
|
|
229
232
|
|
|
230
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
|
+
|
|
231
247
|
/** Spawn the agent's headless CLI with the prompt substituted into its argv.
|
|
232
248
|
* `env` (from agentEnv) strips vendor API-billing keys unless the user opted in —
|
|
233
249
|
* a task must never silently bill an API account instead of the owner's subscription. */
|
|
@@ -340,7 +356,13 @@ export function resumeCommand(command, sessionId) {
|
|
|
340
356
|
|
|
341
357
|
function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
342
358
|
return new Promise((resolve, reject) => {
|
|
343
|
-
|
|
359
|
+
// IDENTITY: a claude run with a per-agent token carries its OWN Cookbook
|
|
360
|
+
// connection (--strict-mcp-config), so it acts as this Bridge's member under the
|
|
361
|
+
// agent's name — never as whatever the CLI is logged in as, and blind to stale
|
|
362
|
+
// claude.ai connectors that poison headless runs (2026-08-25).
|
|
363
|
+
const baseCommand = withCookbookMcp
|
|
364
|
+
? withCookbookMcp(opts.command ?? agent.command, { token: agent.token, cookbookUrl: agent.cookbookUrl }).command
|
|
365
|
+
: (opts.command ?? agent.command);
|
|
344
366
|
const { command, streaming } = onProgress ? streamingCommand(baseCommand) : { command: baseCommand, streaming: false };
|
|
345
367
|
const [cmd, ...rawArgs] = command;
|
|
346
368
|
const args = rawArgs.map((a) => a.replaceAll("{prompt}", prompt));
|
|
@@ -363,6 +385,8 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
363
385
|
// deltas. A completed turn REPLACES its partials (same text arrives both ways).
|
|
364
386
|
let turnsText = "";
|
|
365
387
|
let partialText = "";
|
|
388
|
+
// Live CALLS (show the work): tool_use/tool_result folded into a capped list.
|
|
389
|
+
let calls = [];
|
|
366
390
|
const liveText = () => {
|
|
367
391
|
const full = partialText ? `${turnsText}${turnsText ? "\n\n" : ""}${partialText}` : turnsText;
|
|
368
392
|
return full.length > LIVE_TEXT_CAP ? "…" + full.slice(-LIVE_TEXT_CAP) : full;
|
|
@@ -376,7 +400,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
376
400
|
// Bridge restarts (local retryCtx state is trimmed; the task row isn't).
|
|
377
401
|
// Progress needs a token field to pass the server's substance check, so a
|
|
378
402
|
// text-only tick sends output_tokens as-is (0 is fine once input>0 arrives).
|
|
379
|
-
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 */ }
|
|
380
404
|
};
|
|
381
405
|
|
|
382
406
|
let sessionId = null;
|
|
@@ -400,10 +424,14 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
400
424
|
partialText = "";
|
|
401
425
|
}
|
|
402
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; }
|
|
403
431
|
const r = foldStreamLine(line, acc);
|
|
404
432
|
acc = r.acc;
|
|
405
433
|
if (r.resultLine) resultLine = r.resultLine;
|
|
406
|
-
else if (Date.now() - lastEmit > (agent.progressThrottleMs ?? 1200)) emit();
|
|
434
|
+
else if (Date.now() - lastEmit > (touched ? 300 : (agent.progressThrottleMs ?? 1200))) emit();
|
|
407
435
|
}
|
|
408
436
|
});
|
|
409
437
|
child.stderr.on("data", (d) => { lastActivityAt = Date.now(); err += d; });
|
|
@@ -628,8 +656,9 @@ function applyConfigFromDisk(cfg) {
|
|
|
628
656
|
if (raw.cookbookUrl) cfg.cookbookUrl = String(raw.cookbookUrl).replace(/\/$/, "");
|
|
629
657
|
cfg.default = raw.default;
|
|
630
658
|
cfg.localWorkspaces = raw.localWorkspaces ?? {};
|
|
631
|
-
cfg.hosting = raw.hosting ?? {
|
|
659
|
+
cfg.hosting = raw.hosting ?? {};
|
|
632
660
|
const agents = (raw.agents ?? []).filter((a) => a.enabled !== false);
|
|
661
|
+
for (const a of agents) a.cookbookUrl = cfg.cookbookUrl;
|
|
633
662
|
cfg.agents.splice(0, cfg.agents.length, ...agents);
|
|
634
663
|
// A reload usually follows connect-agents fixing the token — let the next poll
|
|
635
664
|
// re-verify from scratch instead of staying stuck in the rejected state.
|
|
@@ -972,7 +1001,7 @@ async function processTask(cfg, ws, task, agent) {
|
|
|
972
1001
|
const live = cfg.liveTokens !== false && agent.liveTokens !== false;
|
|
973
1002
|
const r = warmRunner ?? runnerFor({
|
|
974
1003
|
threadId: threadKey,
|
|
975
|
-
agent,
|
|
1004
|
+
agent: pinnedAgent(cfg, agent),
|
|
976
1005
|
env: agentEnv(cfg).env,
|
|
977
1006
|
resumeSessionId: canResumeThread ? threadSession : null,
|
|
978
1007
|
helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
|
|
@@ -1203,7 +1232,7 @@ function noteAwaiting(awaiting) {
|
|
|
1203
1232
|
}
|
|
1204
1233
|
|
|
1205
1234
|
async function serveHands(cfg, calls) {
|
|
1206
|
-
if (
|
|
1235
|
+
if (hostingMode(cfg) === "off" || handsBusy || !calls || calls.length === 0) return;
|
|
1207
1236
|
handsBusy = true;
|
|
1208
1237
|
try {
|
|
1209
1238
|
await serveCalls(calls, {
|
|
@@ -1236,7 +1265,7 @@ async function serveHands(cfg, calls) {
|
|
|
1236
1265
|
/** Poll for granted calls (the net under the push channel, and the whole story on a
|
|
1237
1266
|
* server or network without SSE). No-ops entirely when not hosting. */
|
|
1238
1267
|
async function pollHands(cfg) {
|
|
1239
|
-
if (
|
|
1268
|
+
if (hostingMode(cfg) === "off" || !hands.supported || handsBusy) return;
|
|
1240
1269
|
try {
|
|
1241
1270
|
const r = await fetchHands(cfg);
|
|
1242
1271
|
if (!r.supported) {
|
|
@@ -1274,7 +1303,7 @@ async function socketLoop(cfg) {
|
|
|
1274
1303
|
let announced = false;
|
|
1275
1304
|
while (!sseStopped && sse.supported) {
|
|
1276
1305
|
try {
|
|
1277
|
-
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream`, {
|
|
1306
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream${agentsQuery(cfg)}`, {
|
|
1278
1307
|
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
1279
1308
|
});
|
|
1280
1309
|
if (res.status === 404 || res.status === 405) {
|
|
@@ -1351,7 +1380,7 @@ async function dispatchWorkInner(cfg, work, warmHints) {
|
|
|
1351
1380
|
if (!agent || agent.runner === "app-server" || agent.runner === "robot") continue;
|
|
1352
1381
|
warmUp({
|
|
1353
1382
|
poolKey: `warm::${h.workspace_id}::${agent.name}`,
|
|
1354
|
-
agent,
|
|
1383
|
+
agent: pinnedAgent(cfg, agent),
|
|
1355
1384
|
env: agentEnv(cfg).env,
|
|
1356
1385
|
helpers: { fold: foldStreamLine, textFrom: textFromStreamLine, sessionFrom: sessionIdFrom },
|
|
1357
1386
|
log,
|
|
@@ -1721,9 +1750,12 @@ async function main() {
|
|
|
1721
1750
|
tokenOk = true;
|
|
1722
1751
|
lastContactAt = Date.now();
|
|
1723
1752
|
log(`Connected — watching ${ws.length} workspace(s).`);
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1753
|
+
{
|
|
1754
|
+
const mode = hostingMode(cfg);
|
|
1755
|
+
if (mode === "always") log("⌂ Hosting is ON — an agent you invite can run granted checks on this machine. You'll see every step; `cookbook-bridge host --off` closes the door.");
|
|
1756
|
+
else if (mode === "grants") log("⌂ Hosting: grants you approve in Cookbook run here (every change still waits for your click). `cookbook-bridge host --off` refuses all.");
|
|
1757
|
+
else log("⌂ Hosting is OFF — no visiting agent can act on this machine. `cookbook-bridge host` opens it.");
|
|
1758
|
+
if (mode !== "off") await pollHands(cfg);
|
|
1727
1759
|
}
|
|
1728
1760
|
if (cfg.persistentThreads) {
|
|
1729
1761
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { killAllRunners(); killCodexServer(); process.exit(0); });
|
|
@@ -1773,7 +1805,7 @@ async function main() {
|
|
|
1773
1805
|
}
|
|
1774
1806
|
// HOSTING: granted calls ride the same cadence as work. When the push channel
|
|
1775
1807
|
// is healthy it has already delivered them; this is the net.
|
|
1776
|
-
if (cfg
|
|
1808
|
+
if (hostingMode(cfg) !== "off" && !pushHealthy) await pollHands(cfg);
|
|
1777
1809
|
// A clean poll means the token is good — clear any prior rejection so the app's
|
|
1778
1810
|
// /status flips back to connected once the user fixes it.
|
|
1779
1811
|
lastContactAt = Date.now();
|
|
@@ -1899,6 +1931,7 @@ async function doctorReport(args) {
|
|
|
1899
1931
|
cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
1900
1932
|
cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
|
|
1901
1933
|
cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
|
|
1934
|
+
for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
|
|
1902
1935
|
if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
|
|
1903
1936
|
bad("Config is missing cookbookUrl or a real token", "set both in config.json (token from your Cookbook → Tokens page)");
|
|
1904
1937
|
cfg = null;
|
|
@@ -1998,6 +2031,11 @@ async function doctorReport(args) {
|
|
|
1998
2031
|
}
|
|
1999
2032
|
}
|
|
2000
2033
|
|
|
2034
|
+
if (isClaudeCommand && isClaudeCommand(agent.command)) {
|
|
2035
|
+
if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
|
|
2036
|
+
else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
|
|
2037
|
+
"run `cookbook-bridge connect` (mints a token for this agent) or add \"token\" to this agent in config.json");
|
|
2038
|
+
}
|
|
2001
2039
|
if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
|
|
2002
2040
|
warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
|
|
2003
2041
|
"if tasks 'run but never complete', switch allowedTools to mcp__cookbook__*");
|
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/cookbook.mjs
CHANGED
|
@@ -239,8 +239,15 @@ export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, lim
|
|
|
239
239
|
// server that predates grants 404s, which every caller treats as "not hosting".
|
|
240
240
|
|
|
241
241
|
/** Pending calls (and the live grants they belong to) for THIS Bridge's token. */
|
|
242
|
+
/** `?agents=Claude,Gemini,Chef` — what this Bridge manages, so the server can say
|
|
243
|
+
* "your Bridge is running but doesn't run X" instead of "start a Bridge". */
|
|
244
|
+
export function agentsQuery(cfg) {
|
|
245
|
+
const names = (cfg?.agents ?? []).filter((a) => a && a.enabled !== false && a.name).map((a) => String(a.name));
|
|
246
|
+
return names.length ? `?agents=${encodeURIComponent(names.join(","))}` : "";
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
export async function fetchHands(cfg) {
|
|
243
|
-
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
|
|
250
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands${agentsQuery(cfg)}`, {
|
|
244
251
|
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
245
252
|
});
|
|
246
253
|
if (res.status === 404) return { supported: false, calls: [], grants: [] };
|
package/device.mjs
CHANGED
|
@@ -291,6 +291,9 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
291
291
|
const add = spawnSync(cli.path, ["mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`], { encoding: "utf8", timeout: 30_000 });
|
|
292
292
|
if (add.status === 0) results.push({ agent: cli.agent, ok: true, detail: "connected (server 'cookbook', user scope)" });
|
|
293
293
|
else results.push({ agent: cli.agent, ok: false, detail: String(add.stderr || add.stdout || "add failed").trim().slice(0, 200) });
|
|
294
|
+
// The Bridge's OWN runs must not depend on the CLI's global server: store the
|
|
295
|
+
// token on the agent so spawnAgent pins each run to it (--strict-mcp-config).
|
|
296
|
+
if (/claude/i.test(cli.agent)) setAgentToken(cfgPath, /claude/i, token);
|
|
294
297
|
} else if (cli.kind === "file") {
|
|
295
298
|
const wrote = agyConfigure(mcpUrl, token);
|
|
296
299
|
results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
|
|
@@ -314,6 +317,16 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
314
317
|
return results;
|
|
315
318
|
}
|
|
316
319
|
|
|
320
|
+
/** Bridge config: give the agent whose command matches `re` its attributed token. */
|
|
321
|
+
function setAgentToken(cfgPath, re, token) {
|
|
322
|
+
const raw = readConfig(cfgPath);
|
|
323
|
+
if (!raw || !Array.isArray(raw.agents)) return;
|
|
324
|
+
const agent = raw.agents.find((a) => a && re.test(String(a.command?.[0] ?? a.name ?? "")));
|
|
325
|
+
if (!agent) return;
|
|
326
|
+
agent.token = token;
|
|
327
|
+
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
/** Bridge config: make sure a Codex agent exists, is enabled, and carries its pieces. */
|
|
318
331
|
function enableCodexAgent(cfgPath, { binary, codexHome, token }) {
|
|
319
332
|
const raw = readConfig(cfgPath);
|
package/hands.mjs
CHANGED
|
@@ -568,14 +568,26 @@ export const RUN_TEMPLATES = Object.freeze({
|
|
|
568
568
|
|
|
569
569
|
tail_log: (params, ctx) => {
|
|
570
570
|
const n = Math.min(Math.max(parseInt(params?.lines ?? 120, 10) || 120, 10), 400);
|
|
571
|
-
|
|
571
|
+
// Where this Bridge's log actually is: `bridge.log` beside the config (terminal),
|
|
572
|
+
// the newest `*.log` beside it (a LaunchAgent), or the newest in `logs/` (the
|
|
573
|
+
// desktop app). Chef's first look at a desktop-app machine got "no bridge.log
|
|
574
|
+
// yet" for a Bridge that was logging fine (2026-08-25).
|
|
575
|
+
const dir = path.dirname(ctx.cfgPath || "");
|
|
576
|
+
const newest = (d) => {
|
|
577
|
+
try {
|
|
578
|
+
return fs.readdirSync(d).filter((f) => f.endsWith(".log")).map((f) => path.join(d, f))
|
|
579
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] ?? null;
|
|
580
|
+
} catch { return null; }
|
|
581
|
+
};
|
|
582
|
+
const logPath = [path.join(dir, "bridge.log"), newest(path.join(dir, "logs")), newest(dir)]
|
|
583
|
+
.find((p) => p && fs.existsSync(p)) ?? path.join(dir, "bridge.log");
|
|
572
584
|
return { local: () => {
|
|
573
585
|
let text = "";
|
|
574
586
|
try {
|
|
575
587
|
const buf = fs.readFileSync(logPath, "utf8");
|
|
576
588
|
text = buf.split("\n").slice(-n).join("\n");
|
|
577
589
|
} catch (e) {
|
|
578
|
-
return { error: `no
|
|
590
|
+
return { error: `no Bridge log yet (${e.code || e.message})` };
|
|
579
591
|
}
|
|
580
592
|
return { path: logPath, lines: n, text };
|
|
581
593
|
} };
|
|
@@ -922,3 +934,24 @@ export function tomlLooksValid(text) {
|
|
|
922
934
|
}
|
|
923
935
|
return !multi && depth === 0;
|
|
924
936
|
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* HOSTING MODE — may this Bridge serve a visiting agent's calls?
|
|
940
|
+
*
|
|
941
|
+
* "always" — `hosting.enabled: true` (`cookbook-bridge host`): serve any grant.
|
|
942
|
+
* "off" — `hosting.enabled: false` (`cookbook-bridge host --off`): refuse all.
|
|
943
|
+
* "grants" — nothing configured: serve the grants this member approved themselves.
|
|
944
|
+
*
|
|
945
|
+
* The third is the default ON PURPOSE (2026-08-25). A grant only exists because the
|
|
946
|
+
* host clicked Allow, in their own account, and it binds to this Bridge's own
|
|
947
|
+
* credential — that click IS the consent. Making it also require a separate "hosting"
|
|
948
|
+
* switch produced the failure Diego hit: Allow in a browser, a Bridge with the switch
|
|
949
|
+
* off, and every call sat queued until the grant expired. The Bridge's LOCAL_CEILING
|
|
950
|
+
* still holds every write at "ask", whatever the mode.
|
|
951
|
+
*/
|
|
952
|
+
export function hostingMode(cfg) {
|
|
953
|
+
const v = cfg?.hosting?.enabled;
|
|
954
|
+
if (v === true) return "always";
|
|
955
|
+
if (v === false) return "off";
|
|
956
|
+
return "grants";
|
|
957
|
+
}
|
package/harden.mjs
CHANGED
|
@@ -115,3 +115,42 @@ export async function checkAgyVersion(argv, timeoutMs = 10_000) {
|
|
|
115
115
|
const version = await probeVersion(argv, timeoutMs);
|
|
116
116
|
return { version, tooOld: version ? versionLt(version, AGY_MIN_VERSION) : false };
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
// ── identity: a run acts as the Bridge's member, not as whoever the CLI is ──────
|
|
120
|
+
//
|
|
121
|
+
// Verified failure (2026-08-25): a Bridge-run `claude -p` refused every Cookbook
|
|
122
|
+
// tool because a stale claude.ai-synced connector poisoned the headless session,
|
|
123
|
+
// and when tools did work they acted as the CLI's own login — a different account
|
|
124
|
+
// that couldn't see the workspace, so complete_task returned "Not found". Both are
|
|
125
|
+
// the same mistake: letting the run inherit the CLI's global MCP state.
|
|
126
|
+
//
|
|
127
|
+
// `--strict-mcp-config --mcp-config <json>` makes the run see ONLY Cookbook, as the
|
|
128
|
+
// member whose token this is, attributed under that token's name.
|
|
129
|
+
|
|
130
|
+
/** True when this command runs the claude CLI (any path, any wrapper flags). */
|
|
131
|
+
export function isClaudeCommand(command) {
|
|
132
|
+
const base = String(command?.[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
133
|
+
return base === "claude";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Rewrite a claude command so the run carries its own Cookbook connection.
|
|
138
|
+
* Pure. No-op (and says so) when it isn't claude, has no token, or the command
|
|
139
|
+
* already pins an MCP config by hand.
|
|
140
|
+
*/
|
|
141
|
+
export function withCookbookMcp(command, { token, cookbookUrl } = {}) {
|
|
142
|
+
if (!Array.isArray(command) || !isClaudeCommand(command)) return { command, injected: false, reason: "not claude" };
|
|
143
|
+
if (!token) return { command, injected: false, reason: "no token" };
|
|
144
|
+
if (!cookbookUrl) return { command, injected: false, reason: "no cookbookUrl" };
|
|
145
|
+
if (command.includes("--mcp-config") || command.includes("--strict-mcp-config")) return { command, injected: false, reason: "already pinned" };
|
|
146
|
+
const cfg = JSON.stringify({
|
|
147
|
+
mcpServers: {
|
|
148
|
+
cookbook: {
|
|
149
|
+
type: "http",
|
|
150
|
+
url: `${String(cookbookUrl).replace(/\/$/, "")}/api/mcp`,
|
|
151
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return { command: [command[0], "--strict-mcp-config", "--mcp-config", cfg, ...command.slice(1)], injected: true, reason: null };
|
|
156
|
+
}
|
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/local.mjs
CHANGED
|
@@ -238,7 +238,7 @@ export function createLocalServer(deps) {
|
|
|
238
238
|
localWorkspaces: localWorkspacesView(),
|
|
239
239
|
// HARDWARE GRANTS (0069): is this machine currently willing to host a visiting
|
|
240
240
|
// agent, and what is live right now? The desktop app renders this as the door.
|
|
241
|
-
hosting: { enabled: !!cfg.hosting?.enabled, activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
|
|
241
|
+
hosting: { enabled: !!cfg.hosting?.enabled, mode: cfg.hosting?.enabled === true ? "always" : cfg.hosting?.enabled === false ? "off" : "grants", activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
|
|
242
242
|
hotWorkspaceIds: deps.hotWorkspaceIds ? [...deps.hotWorkspaceIds()] : [],
|
|
243
243
|
lastError: deps.lastError ? deps.lastError() : null,
|
|
244
244
|
connect: { state: connect.state },
|
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(() => {
|