@cargo-ai/cli 1.0.94 → 1.0.95
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/build/commands/ai/chat.d.ts.map +1 -1
- package/build/commands/ai/chat.js +2 -1
- package/build/commands/ai/message.d.ts.map +1 -1
- package/build/commands/ai/message.js +2 -1
- package/build/commands/ai/release.d.ts.map +1 -1
- package/build/commands/ai/release.js +7 -6
- package/build/commands/ask/harness/approvals.d.ts +7 -0
- package/build/commands/ask/harness/approvals.d.ts.map +1 -0
- package/build/commands/ask/harness/approvals.js +82 -0
- package/build/commands/ask/harness/artifacts.d.ts.map +1 -0
- package/build/commands/ask/harness/claudeConfig.d.ts.map +1 -0
- package/build/commands/ask/harness/create.d.ts +59 -0
- package/build/commands/ask/harness/create.d.ts.map +1 -0
- package/build/commands/ask/harness/create.js +292 -0
- package/build/commands/ask/harness/index.d.ts +8 -0
- package/build/commands/ask/harness/index.d.ts.map +1 -0
- package/build/commands/ask/harness/index.js +7 -0
- package/build/commands/ask/harness/ingest.d.ts +25 -0
- package/build/commands/ask/harness/ingest.d.ts.map +1 -0
- package/build/commands/ask/harness/ingest.js +35 -0
- package/build/commands/ask/harness/instructions.d.ts +13 -0
- package/build/commands/ask/harness/instructions.d.ts.map +1 -0
- package/build/commands/ask/harness/instructions.js +41 -0
- package/build/commands/ask/harness/layout.d.ts +23 -0
- package/build/commands/ask/harness/layout.d.ts.map +1 -0
- package/build/commands/ask/harness/layout.js +32 -0
- package/build/commands/ask/harness/mcp.d.ts +25 -0
- package/build/commands/ask/harness/mcp.d.ts.map +1 -0
- package/build/commands/ask/harness/mcp.js +72 -0
- package/build/commands/ask/harness/pnpmShim.d.ts.map +1 -0
- package/build/commands/ask/harness/release.d.ts +18 -0
- package/build/commands/ask/harness/release.d.ts.map +1 -0
- package/build/commands/ask/harness/release.js +117 -0
- package/build/commands/ask/harness/sandbox.d.ts +9 -0
- package/build/commands/ask/harness/sandbox.d.ts.map +1 -0
- package/build/commands/ask/harness/sandbox.js +27 -0
- package/build/commands/ask/harness/timing.d.ts +16 -0
- package/build/commands/ask/harness/timing.d.ts.map +1 -0
- package/build/commands/ask/harness/timing.js +72 -0
- package/build/commands/ask/harness/turn.d.ts +145 -0
- package/build/commands/ask/harness/turn.d.ts.map +1 -0
- package/build/commands/ask/harness/turn.js +626 -0
- package/build/commands/ask/harness/usage.d.ts +50 -0
- package/build/commands/ask/harness/usage.d.ts.map +1 -0
- package/build/commands/ask/harness/usage.js +87 -0
- package/build/commands/ask/index.d.ts +9 -0
- package/build/commands/ask/index.d.ts.map +1 -1
- package/build/commands/ask/index.js +7 -7
- package/build/commands/ask/input/completer.d.ts.map +1 -0
- package/build/commands/ask/input/index.d.ts +5 -0
- package/build/commands/ask/input/index.d.ts.map +1 -0
- package/build/commands/ask/input/index.js +4 -0
- package/build/commands/ask/input/prompt.d.ts.map +1 -0
- package/build/commands/ask/{prompt.js → input/prompt.js} +1 -1
- package/build/commands/ask/input/slash.d.ts.map +1 -0
- package/build/commands/ask/{slash.js → input/slash.js} +1 -1
- package/build/commands/ask/input/typeAhead.d.ts.map +1 -0
- package/build/commands/ask/{history.d.ts → persist/history.d.ts} +1 -1
- package/build/commands/ask/persist/history.d.ts.map +1 -0
- package/build/commands/ask/persist/index.d.ts +4 -0
- package/build/commands/ask/persist/index.d.ts.map +1 -0
- package/build/commands/ask/persist/index.js +3 -0
- package/build/commands/ask/persist/persist.d.ts.map +1 -0
- package/build/commands/ask/persist/state.d.ts.map +1 -0
- package/build/commands/ask/session/claim.d.ts +56 -0
- package/build/commands/ask/session/claim.d.ts.map +1 -0
- package/build/commands/ask/session/claim.js +76 -0
- package/build/commands/ask/session/index.d.ts +5 -0
- package/build/commands/ask/session/index.d.ts.map +1 -0
- package/build/commands/ask/session/index.js +4 -0
- package/build/commands/ask/session/run.d.ts +36 -0
- package/build/commands/ask/session/run.d.ts.map +1 -0
- package/build/commands/ask/session/run.js +832 -0
- package/build/commands/ask/session/thinking.d.ts.map +1 -0
- package/build/commands/ask/session/tree.d.ts +90 -0
- package/build/commands/ask/session/tree.d.ts.map +1 -0
- package/build/commands/ask/session/tree.js +318 -0
- package/package.json +6 -5
- package/build/commands/ask/artifacts.d.ts.map +0 -1
- package/build/commands/ask/binding.d.ts +0 -13
- package/build/commands/ask/binding.d.ts.map +0 -1
- package/build/commands/ask/binding.js +0 -19
- package/build/commands/ask/claudeConfig.d.ts.map +0 -1
- package/build/commands/ask/completer.d.ts.map +0 -1
- package/build/commands/ask/history.d.ts.map +0 -1
- package/build/commands/ask/localHarness.d.ts +0 -278
- package/build/commands/ask/localHarness.d.ts.map +0 -1
- package/build/commands/ask/localHarness.js +0 -1300
- package/build/commands/ask/messageStream.d.ts +0 -33
- package/build/commands/ask/messageStream.d.ts.map +0 -1
- package/build/commands/ask/messageStream.js +0 -114
- package/build/commands/ask/persist.d.ts.map +0 -1
- package/build/commands/ask/pnpmShim.d.ts.map +0 -1
- package/build/commands/ask/prompt.d.ts.map +0 -1
- package/build/commands/ask/session.d.ts +0 -101
- package/build/commands/ask/session.d.ts.map +0 -1
- package/build/commands/ask/session.js +0 -1328
- package/build/commands/ask/slash.d.ts.map +0 -1
- package/build/commands/ask/state.d.ts.map +0 -1
- package/build/commands/ask/thinking.d.ts.map +0 -1
- package/build/commands/ask/typeAhead.d.ts.map +0 -1
- /package/build/commands/ask/{artifacts.d.ts → harness/artifacts.d.ts} +0 -0
- /package/build/commands/ask/{artifacts.js → harness/artifacts.js} +0 -0
- /package/build/commands/ask/{claudeConfig.d.ts → harness/claudeConfig.d.ts} +0 -0
- /package/build/commands/ask/{claudeConfig.js → harness/claudeConfig.js} +0 -0
- /package/build/commands/ask/{pnpmShim.d.ts → harness/pnpmShim.d.ts} +0 -0
- /package/build/commands/ask/{pnpmShim.js → harness/pnpmShim.js} +0 -0
- /package/build/commands/ask/{completer.d.ts → input/completer.d.ts} +0 -0
- /package/build/commands/ask/{completer.js → input/completer.js} +0 -0
- /package/build/commands/ask/{prompt.d.ts → input/prompt.d.ts} +0 -0
- /package/build/commands/ask/{slash.d.ts → input/slash.d.ts} +0 -0
- /package/build/commands/ask/{typeAhead.d.ts → input/typeAhead.d.ts} +0 -0
- /package/build/commands/ask/{typeAhead.js → input/typeAhead.js} +0 -0
- /package/build/commands/ask/{history.js → persist/history.js} +0 -0
- /package/build/commands/ask/{persist.d.ts → persist/persist.d.ts} +0 -0
- /package/build/commands/ask/{persist.js → persist/persist.js} +0 -0
- /package/build/commands/ask/{state.d.ts → persist/state.d.ts} +0 -0
- /package/build/commands/ask/{state.js → persist/state.js} +0 -0
- /package/build/commands/ask/{thinking.d.ts → session/thinking.d.ts} +0 -0
- /package/build/commands/ask/{thinking.js → session/thinking.js} +0 -0
|
@@ -1,1300 +0,0 @@
|
|
|
1
|
-
import { accessSync, constants } from "node:fs";
|
|
2
|
-
import { basename, dirname } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { HarnessAgent } from "@ai-sdk/harness/agent";
|
|
5
|
-
import { createClaudeCode } from "@ai-sdk/harness-claude-code";
|
|
6
|
-
import { cdkProjectRoot } from "@cargo-ai/cdk/cli";
|
|
7
|
-
import { createLocalSandbox } from "ai-sdk-sandbox-local";
|
|
8
|
-
import { pruneHarnessArtifacts } from "./artifacts.js";
|
|
9
|
-
import { askClaudeConfigDir } from "./claudeConfig.js";
|
|
10
|
-
import { ensureHarnessPnpmOnPath } from "./pnpmShim.js";
|
|
11
|
-
/**
|
|
12
|
-
* A local turn spends far longer getting the prompt into Claude Code than it
|
|
13
|
-
* spends generating the answer, and none of that is visible from the tree or
|
|
14
|
-
* from Claude Code's own transcript. `CARGO_ASK_TIMING=1` writes the phase
|
|
15
|
-
* boundaries — and the harness's own diagnostics — to stderr. Unset, every
|
|
16
|
-
* call below is a no-op and nothing is added to the stream.
|
|
17
|
-
*
|
|
18
|
-
* `CARGO_ASK_TIMING=parts` adds the whole of each stream part next to its
|
|
19
|
-
* type. Verbose, and the only way to see what the bridge actually sends: the
|
|
20
|
-
* part names alone say a tool call arrived, not that its result came back
|
|
21
|
-
* empty or that the field a reader is looking for is spelled differently here
|
|
22
|
-
* than in the bridge that emitted it.
|
|
23
|
-
*/
|
|
24
|
-
const ASK_TIMING_LEVEL = process.env["CARGO_ASK_TIMING"];
|
|
25
|
-
const ASK_TIMING_ENABLED = ASK_TIMING_LEVEL === "1" || ASK_TIMING_LEVEL === "parts";
|
|
26
|
-
const ASK_TIMING_DUMPS_PARTS = ASK_TIMING_LEVEL === "parts";
|
|
27
|
-
function createPhaseLog(label) {
|
|
28
|
-
if (ASK_TIMING_ENABLED === false) {
|
|
29
|
-
return () => undefined;
|
|
30
|
-
}
|
|
31
|
-
const start = Date.now();
|
|
32
|
-
let previous = start;
|
|
33
|
-
return (phase) => {
|
|
34
|
-
const now = Date.now();
|
|
35
|
-
const total = ((now - start) / 1000).toFixed(2).padStart(6);
|
|
36
|
-
const step = ((now - previous) / 1000).toFixed(2).padStart(5);
|
|
37
|
-
previous = now;
|
|
38
|
-
process.stderr.write(`[ask:timing] ${label} ${total}s (+${step}s) ${phase}\n`);
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* The harness prints its own diagnostics without a clock, which is what the
|
|
43
|
-
* gap needs. Reprint them relative to process start instead.
|
|
44
|
-
*/
|
|
45
|
-
function reportHarnessDiagnostic(event) {
|
|
46
|
-
const elapsed = process.uptime().toFixed(2).padStart(6);
|
|
47
|
-
process.stderr.write(`[ask:timing] harness ${elapsed}s ${event.level} ${event.subsystem ?? "-"} ${event.message}\n`);
|
|
48
|
-
}
|
|
49
|
-
/** Marks when each part of a turn's stream arrives, and when it ends. */
|
|
50
|
-
function timeStreamParts(stream, phase) {
|
|
51
|
-
if (ASK_TIMING_ENABLED === false) {
|
|
52
|
-
return stream;
|
|
53
|
-
}
|
|
54
|
-
return { text: stream.text, fullStream: timeParts(stream.fullStream, phase) };
|
|
55
|
-
}
|
|
56
|
-
async function* timeParts(parts, phase) {
|
|
57
|
-
let seen = 0;
|
|
58
|
-
for await (const part of parts) {
|
|
59
|
-
seen += 1;
|
|
60
|
-
const type = typeof part === "object" && part !== null && "type" in part
|
|
61
|
-
? String(part.type)
|
|
62
|
-
: "unknown";
|
|
63
|
-
// Text arrives one delta per token, which would bury everything else.
|
|
64
|
-
if (type.startsWith("text-delta") === false) {
|
|
65
|
-
phase(ASK_TIMING_DUMPS_PARTS === true
|
|
66
|
-
? `part ${String(seen)}: ${type} ${dumpPart(part)}`
|
|
67
|
-
: `part ${String(seen)}: ${type}`);
|
|
68
|
-
}
|
|
69
|
-
yield part;
|
|
70
|
-
}
|
|
71
|
-
phase(`stream ended after ${String(seen)} parts`);
|
|
72
|
-
}
|
|
73
|
-
/** One stream part, flattened onto its log line. Truncated: some are huge. */
|
|
74
|
-
function dumpPart(part) {
|
|
75
|
-
try {
|
|
76
|
-
const json = JSON.stringify(part, (_key, value) => value instanceof Error ? `Error(${value.message})` : value);
|
|
77
|
-
return json === undefined ? "" : json.slice(0, 900);
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return "<unserializable>";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const LOCAL_WORKING_COPY_INSTRUCTIONS = `You are working in the user's local working copy, not a sandbox. Prefer plan over deploy.
|
|
84
|
-
|
|
85
|
-
The user confirms each Bash, Write, Edit and NotebookEdit call, and can only answer one at a time. Ask for a single one of those per step and wait for its result before asking for the next; two in one step leaves the second waiting on a confirmation it will never be offered. Reads (Read, Glob, Grep) and the Cargo \`mcp__cargo__*\` tools need no confirmation — batch those freely.
|
|
86
|
-
|
|
87
|
-
Prefer minimal, focused diffs; match the existing code style; run the project's own checks when they exist. End each task with a short summary of what changed and how it was validated.`;
|
|
88
|
-
const CDK_RAILS_INSTRUCTIONS = `This directory is a Cargo CDK project.
|
|
89
|
-
- Never edit cargo.state.json, cargo.state.lock, or .cargo-ai/ by hand — they are generated. Repoint this project's deploy state with \`cargo-cdk state bind\`.
|
|
90
|
-
- Validate your changes before declaring them done: run \`npx tsc --noEmit\` and \`npx cargo-cdk plan\` (plan only reads — it changes nothing).
|
|
91
|
-
- Never run \`cargo-cdk deploy\`, \`destroy\`, \`import\`, or \`rollback\` unless the user explicitly asked.`;
|
|
92
|
-
const LOCAL_CDK_RAILS_SKILL = {
|
|
93
|
-
name: "cdk-rails",
|
|
94
|
-
description: "Rails for working on a Cargo CDK project on this machine.",
|
|
95
|
-
content: CDK_RAILS_INSTRUCTIONS,
|
|
96
|
-
};
|
|
97
|
-
/**
|
|
98
|
-
* Always-on local-copy rails, plus CDK rails only when `dir` is a CDK project.
|
|
99
|
-
* Exported for tests.
|
|
100
|
-
*/
|
|
101
|
-
export function localHarnessInstructions(dir) {
|
|
102
|
-
if (cdkProjectRoot(dir) !== undefined) {
|
|
103
|
-
return `${LOCAL_WORKING_COPY_INSTRUCTIONS}\n\n${CDK_RAILS_INSTRUCTIONS}`;
|
|
104
|
-
}
|
|
105
|
-
return LOCAL_WORKING_COPY_INSTRUCTIONS;
|
|
106
|
-
}
|
|
107
|
-
/** Exported for tests. */
|
|
108
|
-
export function localHarnessSkills(dir) {
|
|
109
|
-
if (cdkProjectRoot(dir) !== undefined) {
|
|
110
|
-
return [LOCAL_CDK_RAILS_SKILL];
|
|
111
|
-
}
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
/** Exported for tests. */
|
|
115
|
-
export function createTurnAccumulator() {
|
|
116
|
-
return {
|
|
117
|
-
chunks: [],
|
|
118
|
-
usage: {
|
|
119
|
-
costUsd: 0,
|
|
120
|
-
totalTokens: 0,
|
|
121
|
-
inputTokens: 0,
|
|
122
|
-
outputTokens: 0,
|
|
123
|
-
cacheReadTokens: 0,
|
|
124
|
-
cacheWriteTokens: 0,
|
|
125
|
-
reasoningTokens: 0,
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* `ai-sdk-sandbox-local@0.1.2` still ships deprecated `getPortUrl` only.
|
|
131
|
-
* Claude Code's harness treats a session without `getPortEndpoint` as a basic
|
|
132
|
-
* sandbox and then demands an explicit port — wrap until the local provider
|
|
133
|
-
* catches up.
|
|
134
|
-
*/
|
|
135
|
-
function withGetPortEndpoint(provider) {
|
|
136
|
-
const resumeSession = provider.resumeSession;
|
|
137
|
-
return {
|
|
138
|
-
specificationVersion: provider.specificationVersion,
|
|
139
|
-
providerId: provider.providerId,
|
|
140
|
-
createSession: async (options) => ensureGetPortEndpoint(await provider.createSession(options)),
|
|
141
|
-
resumeSession: resumeSession === undefined
|
|
142
|
-
? undefined
|
|
143
|
-
: async (options) => ensureGetPortEndpoint(await resumeSession(options)),
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
function ensureGetPortEndpoint(session) {
|
|
147
|
-
if ("getPortEndpoint" in session) {
|
|
148
|
-
return session;
|
|
149
|
-
}
|
|
150
|
-
const getPortEndpoint = async (options) => ({
|
|
151
|
-
url: await session.getPortUrl(options),
|
|
152
|
-
});
|
|
153
|
-
Object.assign(session, { getPortEndpoint });
|
|
154
|
-
return session;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* The workspace half of local Ask: the hosted Cargo MCP server, reached
|
|
158
|
-
* through this CLI's own `mcp` bridge.
|
|
159
|
-
*
|
|
160
|
-
* Going through the bridge rather than configuring the HTTP endpoint directly
|
|
161
|
-
* is what keeps a long chat working — the bridge rebuilds the `Authorization`
|
|
162
|
-
* header per request from the same credentials every other command uses, so an
|
|
163
|
-
* OAuth session that expires mid-session renews instead of 401ing. `CARGO_BASE_URL`
|
|
164
|
-
* is pinned so the bridge cannot end up on a different API than this session.
|
|
165
|
-
*
|
|
166
|
-
* Everything the workspace can do arrives this way: actions, runs, models and
|
|
167
|
-
* SQL, the context, usage. That includes `execute_action`, which spends credits
|
|
168
|
-
* and writes to connected systems — see the approval note on
|
|
169
|
-
* `isAutoApproved`.
|
|
170
|
-
*/
|
|
171
|
-
/**
|
|
172
|
-
* What Claude Code mounts this server as, and so the `mcp__<server>__` prefix
|
|
173
|
-
* its tools carry. Read by `isAutoApproved`, which trusts this server and no
|
|
174
|
-
* other — see the note there.
|
|
175
|
-
*
|
|
176
|
-
* Exported for tests.
|
|
177
|
-
*/
|
|
178
|
-
export const CARGO_MCP_SERVER_NAME = "cargo";
|
|
179
|
-
function cargoMcpServer(apiBaseUrl) {
|
|
180
|
-
return {
|
|
181
|
-
type: "stdio",
|
|
182
|
-
command: process.execPath,
|
|
183
|
-
args: [...loaderArgs(), cliEntryPath(), "mcp"],
|
|
184
|
-
env: { CARGO_BASE_URL: apiBaseUrl },
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* This CLI's own entry, so the bridge is the same code and version as the
|
|
189
|
-
* session that spawned it — not whatever `cargo-ai` happens to be on PATH.
|
|
190
|
-
*/
|
|
191
|
-
function cliEntryPath() {
|
|
192
|
-
const entry = process.argv[1];
|
|
193
|
-
return entry !== undefined && entry.length > 0
|
|
194
|
-
? entry
|
|
195
|
-
: fileURLToPath(new URL("../../index.js", import.meta.url));
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Loader flags only. A dev run under `tsx` starts from a `.ts` entry that bare
|
|
199
|
-
* node cannot execute, so the child needs the same loader; the rest of
|
|
200
|
-
* `execArgv` (`--inspect` above all, which would fight over a port) must not
|
|
201
|
-
* be inherited.
|
|
202
|
-
*/
|
|
203
|
-
function loaderArgs() {
|
|
204
|
-
const flags = ["--import", "--loader", "--require"];
|
|
205
|
-
const args = [];
|
|
206
|
-
for (let index = 0; index < process.execArgv.length; index += 1) {
|
|
207
|
-
const arg = process.execArgv[index];
|
|
208
|
-
if (arg === undefined) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
// `--import=tsx` carries its value; `--import tsx` is two entries and the
|
|
212
|
-
// second one has to come along.
|
|
213
|
-
if (flags.some((flag) => arg.startsWith(`${flag}=`)) === true) {
|
|
214
|
-
args.push(arg);
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (flags.includes(arg) === true) {
|
|
218
|
-
const value = process.execArgv[index + 1];
|
|
219
|
-
if (value !== undefined) {
|
|
220
|
-
args.push(arg, value);
|
|
221
|
-
index += 1;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return args;
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Where to root the sandbox, and which directory inside it the session works in.
|
|
229
|
-
*
|
|
230
|
-
* The harness gives every session a work directory under the sandbox root and
|
|
231
|
-
* starts Claude Code there — `cwd: workdir` in the bridge — and it refuses to
|
|
232
|
-
* use the root itself. Rooted at the project, that means the agent begins in an
|
|
233
|
-
* empty `claude-code-<sessionId>` directory one level below the working copy it
|
|
234
|
-
* was asked about. Rooting at the project's parent instead makes the session
|
|
235
|
-
* work directory *be* the project, so the agent starts where the user is, and
|
|
236
|
-
* the harness's own artifacts land beside the project rather than in it.
|
|
237
|
-
*
|
|
238
|
-
* Falls back to the project when the parent cannot serve — a project at a
|
|
239
|
-
* filesystem root has no usable name, and a parent we cannot write to would
|
|
240
|
-
* fail the session at startup. The per-session directory returns in that case,
|
|
241
|
-
* and `pruneHarnessArtifacts` sweeps it once it is cold.
|
|
242
|
-
*/
|
|
243
|
-
export function resolveWorkspaceLayout(dir) {
|
|
244
|
-
const parent = dirname(dir);
|
|
245
|
-
const name = basename(dir);
|
|
246
|
-
if (parent === dir || name.length === 0) {
|
|
247
|
-
return { sandboxCwd: dir, workDir: undefined };
|
|
248
|
-
}
|
|
249
|
-
try {
|
|
250
|
-
accessSync(parent, constants.W_OK);
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
return { sandboxCwd: dir, workDir: undefined };
|
|
254
|
-
}
|
|
255
|
-
return { sandboxCwd: parent, workDir: name };
|
|
256
|
-
}
|
|
257
|
-
export async function createLocalHarness(input) {
|
|
258
|
-
const setupPhase = createPhaseLog("setup");
|
|
259
|
-
// Claude Code bootstrap shells out to `pnpm`; Volta often has a dead shim
|
|
260
|
-
// and pnpm 9 refuses this npm packageManager root — put pnpm@10 first.
|
|
261
|
-
ensureHarnessPnpmOnPath();
|
|
262
|
-
setupPhase("pnpm shim written to PATH");
|
|
263
|
-
const workspace = resolveWorkspaceLayout(input.dir);
|
|
264
|
-
pruneHarnessArtifacts(workspace.sandboxCwd);
|
|
265
|
-
// Earlier versions rooted the sandbox at the project, so its artifacts are
|
|
266
|
-
// still in there to collect.
|
|
267
|
-
if (workspace.sandboxCwd !== input.dir) {
|
|
268
|
-
pruneHarnessArtifacts(input.dir);
|
|
269
|
-
}
|
|
270
|
-
setupPhase("stale harness artifacts pruned");
|
|
271
|
-
const anthropicBaseUrl = `${input.apiBaseUrl.replace(/\/$/, "")}/sandbox/anthropic`;
|
|
272
|
-
const harnessAgent = new HarnessAgent({
|
|
273
|
-
// Pin auth the same way the backend harness stream does. Credentials go
|
|
274
|
-
// through `auth`, not `env`: passing them via `env` alone lets
|
|
275
|
-
// createClaudeCode auto-detect AI Gateway from the host process and ignore
|
|
276
|
-
// the Cargo proxy.
|
|
277
|
-
harness: createClaudeCode({
|
|
278
|
-
auth: {
|
|
279
|
-
ANTHROPIC_BASE_URL: anthropicBaseUrl,
|
|
280
|
-
ANTHROPIC_API_KEY: input.proxyToken,
|
|
281
|
-
},
|
|
282
|
-
env: { CLAUDE_CONFIG_DIR: askClaudeConfigDir() },
|
|
283
|
-
mcpServers: {
|
|
284
|
-
[CARGO_MCP_SERVER_NAME]: cargoMcpServer(input.apiBaseUrl),
|
|
285
|
-
},
|
|
286
|
-
}),
|
|
287
|
-
id: "cargo-ai-ask",
|
|
288
|
-
sandbox: withGetPortEndpoint(createLocalSandbox({ cwd: workspace.sandboxCwd })),
|
|
289
|
-
sandboxConfig: workspace.workDir === undefined ? {} : { workDir: workspace.workDir },
|
|
290
|
-
instructions: localHarnessInstructions(input.dir),
|
|
291
|
-
skills: localHarnessSkills(input.dir),
|
|
292
|
-
permissionMode: "allow-reads",
|
|
293
|
-
...(ASK_TIMING_ENABLED === false
|
|
294
|
-
? {}
|
|
295
|
-
: { debug: { enabled: true }, onLog: reportHarnessDiagnostic }),
|
|
296
|
-
});
|
|
297
|
-
setupPhase("harness agent constructed");
|
|
298
|
-
// Local sandbox cannot broker credentials via request transforms; the
|
|
299
|
-
// harness always warns. Keep that off the Ask tree (stderr mid-reply).
|
|
300
|
-
const session = await withMutedCredentialBrokerWarning(() => harnessAgent.createSession());
|
|
301
|
-
setupPhase("harness session created");
|
|
302
|
-
// Consumed by the first prompt: this session has never seen the chat, so it
|
|
303
|
-
// is told the story once rather than on every question.
|
|
304
|
-
let pendingHistory = input.history;
|
|
305
|
-
let proxyLastUsedAtMs;
|
|
306
|
-
// Approvals the previous turn asked about and never answered — a turn
|
|
307
|
-
// cancelled at the confirmation prompt leaves one here.
|
|
308
|
-
const unanswered = [];
|
|
309
|
-
return {
|
|
310
|
-
streamPrompt: async (question, options) => {
|
|
311
|
-
const turnPhase = createPhaseLog("turn ");
|
|
312
|
-
turnPhase("prompt submitted");
|
|
313
|
-
const abortSignal = options === undefined ? undefined : options.abortSignal;
|
|
314
|
-
// The turn is stored and shown as the user wrote it; only what Claude
|
|
315
|
-
// Code is asked carries the replayed history.
|
|
316
|
-
const prompt = pendingHistory === undefined
|
|
317
|
-
? question
|
|
318
|
-
: `${pendingHistory}\n\n${question}`;
|
|
319
|
-
// A cancelled turn stays unfinished, and the harness refuses a new
|
|
320
|
-
// prompt until it is drained. Do that here rather than making the next
|
|
321
|
-
// question fail with "must be continued before accepting a new prompt".
|
|
322
|
-
if (session.hasUnfinishedTurn() === true) {
|
|
323
|
-
// Anything the last turn asked about and never got an answer for is
|
|
324
|
-
// denied. Draining with no continuations leaves the runtime waiting
|
|
325
|
-
// on a response that is never coming, and the honest reading of a
|
|
326
|
-
// user who walked away from the prompt is "no" — carrying the
|
|
327
|
-
// question into a turn they did not ask for would be worse.
|
|
328
|
-
const denials = unanswered.map((approval) => ({
|
|
329
|
-
approvalResponse: {
|
|
330
|
-
type: "tool-approval-response",
|
|
331
|
-
approvalId: approval.approvalId,
|
|
332
|
-
approved: false,
|
|
333
|
-
},
|
|
334
|
-
toolCall: {
|
|
335
|
-
type: "tool-call",
|
|
336
|
-
toolCallId: approval.toolCallId,
|
|
337
|
-
toolName: approval.toolName,
|
|
338
|
-
input: approval.input,
|
|
339
|
-
},
|
|
340
|
-
}));
|
|
341
|
-
unanswered.length = 0;
|
|
342
|
-
// The drain gets its own controller so the timeout can stop it rather
|
|
343
|
-
// than walk away from it. Nothing watches this call the way the stall
|
|
344
|
-
// watchdog watches a turn's stream, so a runtime that never settles
|
|
345
|
-
// would otherwise hang the question just asked — but abandoning the
|
|
346
|
-
// promise and carrying on is no better: `continueGenerate` keeps
|
|
347
|
-
// running, and submitting the new prompt to the same session races the
|
|
348
|
-
// turn we failed to wind down. Chained to the turn's signal so ctrl-c
|
|
349
|
-
// still gets out of it.
|
|
350
|
-
const drain = new AbortController();
|
|
351
|
-
const abortDrain = () => drain.abort();
|
|
352
|
-
if (abortSignal !== undefined) {
|
|
353
|
-
if (abortSignal.aborted === true) {
|
|
354
|
-
drain.abort();
|
|
355
|
-
}
|
|
356
|
-
else {
|
|
357
|
-
abortSignal.addEventListener("abort", abortDrain, { once: true });
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
const drainTimer = setTimeout(abortDrain, TURN_DRAIN_TIMEOUT_MS);
|
|
361
|
-
try {
|
|
362
|
-
await harnessAgent.continueGenerate({
|
|
363
|
-
session,
|
|
364
|
-
toolApprovalContinuations: denials,
|
|
365
|
-
abortSignal: drain.signal,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
catch {
|
|
369
|
-
// Whether it failed or was cut off, the session itself is the only
|
|
370
|
-
// thing that can say if a new prompt is allowed.
|
|
371
|
-
}
|
|
372
|
-
finally {
|
|
373
|
-
clearTimeout(drainTimer);
|
|
374
|
-
if (abortSignal !== undefined) {
|
|
375
|
-
abortSignal.removeEventListener("abort", abortDrain);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
if (session.hasUnfinishedTurn() === true) {
|
|
379
|
-
throw new HarnessTurnStuck();
|
|
380
|
-
}
|
|
381
|
-
turnPhase("unfinished previous turn drained");
|
|
382
|
-
}
|
|
383
|
-
const accumulator = createTurnAccumulator();
|
|
384
|
-
let answer;
|
|
385
|
-
try {
|
|
386
|
-
answer = await runPromptTurn({
|
|
387
|
-
driver: {
|
|
388
|
-
start: async () => {
|
|
389
|
-
turnPhase("harness stream() called");
|
|
390
|
-
const stream = (await harnessAgent.stream({
|
|
391
|
-
prompt,
|
|
392
|
-
session,
|
|
393
|
-
abortSignal,
|
|
394
|
-
}));
|
|
395
|
-
turnPhase("harness stream() returned");
|
|
396
|
-
// The prompt is in the session now, so the replayed history has
|
|
397
|
-
// been told. Cleared here rather than before submitting: a turn
|
|
398
|
-
// that never reached the harness must keep it for the retry, or
|
|
399
|
-
// a cancelled first question would leave every later one in this
|
|
400
|
-
// process without the earlier conversation.
|
|
401
|
-
pendingHistory = undefined;
|
|
402
|
-
return timeStreamParts(stream, turnPhase);
|
|
403
|
-
},
|
|
404
|
-
continueWithApprovals: async (continuations) => {
|
|
405
|
-
turnPhase("harness continueStream() called");
|
|
406
|
-
const stream = (await harnessAgent.continueStream({
|
|
407
|
-
session,
|
|
408
|
-
toolApprovalContinuations: continuations,
|
|
409
|
-
abortSignal,
|
|
410
|
-
}));
|
|
411
|
-
turnPhase("harness continueStream() returned");
|
|
412
|
-
return timeStreamParts(stream, turnPhase);
|
|
413
|
-
},
|
|
414
|
-
},
|
|
415
|
-
handlers: options,
|
|
416
|
-
accumulator,
|
|
417
|
-
unanswered,
|
|
418
|
-
// Interrupt Claude Code through the turn's abort signal so the
|
|
419
|
-
// bridge stops cleanly. Without this the host walks away from the
|
|
420
|
-
// stream and teardown looks like "bridge closed before the turn
|
|
421
|
-
// finished" rather than an intentional stop.
|
|
422
|
-
onStall: options === undefined ? undefined : options.onStall,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
catch (error) {
|
|
426
|
-
// Cancelled or failed after Claude Code already spent tokens. Record
|
|
427
|
-
// what it spent, then let the caller handle the failure.
|
|
428
|
-
if (didReachAnthropic(accumulator.usage) === true) {
|
|
429
|
-
proxyLastUsedAtMs = Date.now();
|
|
430
|
-
}
|
|
431
|
-
await ingestTurn({
|
|
432
|
-
api: input.api,
|
|
433
|
-
chatUuid: input.chatUuid,
|
|
434
|
-
agentUuid: input.agentUuid,
|
|
435
|
-
question,
|
|
436
|
-
answer: accumulator.chunks.join(""),
|
|
437
|
-
usage: accumulator.usage,
|
|
438
|
-
// Whatever it managed to say is a fragment, not the answer. Stored
|
|
439
|
-
// as cancelled so a later session does not replay it as work done.
|
|
440
|
-
cancelled: true,
|
|
441
|
-
turnOwnerId: input.turnOwnerId,
|
|
442
|
-
onNotice: options === undefined ? undefined : options.onNotice,
|
|
443
|
-
});
|
|
444
|
-
throw error;
|
|
445
|
-
}
|
|
446
|
-
if (didReachAnthropic(accumulator.usage) === true) {
|
|
447
|
-
proxyLastUsedAtMs = Date.now();
|
|
448
|
-
}
|
|
449
|
-
// A turn that spent tokens or edited files is recorded even when it
|
|
450
|
-
// produced no prose, so history and billing match what actually ran.
|
|
451
|
-
await ingestTurn({
|
|
452
|
-
api: input.api,
|
|
453
|
-
chatUuid: input.chatUuid,
|
|
454
|
-
agentUuid: input.agentUuid,
|
|
455
|
-
question,
|
|
456
|
-
answer,
|
|
457
|
-
usage: accumulator.usage,
|
|
458
|
-
turnOwnerId: input.turnOwnerId,
|
|
459
|
-
onNotice: options === undefined ? undefined : options.onNotice,
|
|
460
|
-
});
|
|
461
|
-
// Nothing said and nothing spent means the harness never reached
|
|
462
|
-
// Anthropic — a misconfigured proxy rather than a quiet tool-only turn.
|
|
463
|
-
// Same predicate `ingestTurn` uses, so a turn that was billed is never
|
|
464
|
-
// also reported as unreachable.
|
|
465
|
-
if (answer.length === 0 &&
|
|
466
|
-
didReachAnthropic(accumulator.usage) === false) {
|
|
467
|
-
throw new Error(`Local Claude Code returned no text. The harness called ${anthropicBaseUrl} with the minted proxy token — confirm CARGO_BASE_URL points at your API and that /sandbox/anthropic is reachable.`);
|
|
468
|
-
}
|
|
469
|
-
return answer;
|
|
470
|
-
},
|
|
471
|
-
proxyLastUsedAtMs: () => proxyLastUsedAtMs,
|
|
472
|
-
close: async () => {
|
|
473
|
-
// `detach` would leave the bridge and Claude Code running for a resume
|
|
474
|
-
// that never comes, which is how orphaned bridges piled up. Nothing here
|
|
475
|
-
// is resumable by design, so take the runtime down with the session.
|
|
476
|
-
//
|
|
477
|
-
// Bounded, because Ctrl-C at the prompt now unwinds through here: a
|
|
478
|
-
// destroy that never settles would turn "stop" into a hang, which is
|
|
479
|
-
// what pressing it was meant to escape. Giving up can leave the orphan
|
|
480
|
-
// this call exists to prevent, but a teardown already stuck is no more
|
|
481
|
-
// likely to finish while the user waits on it.
|
|
482
|
-
await settleWithin(session.destroy().catch(() => undefined), SESSION_DESTROY_TIMEOUT_MS);
|
|
483
|
-
},
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Persist the turn and what it spent. Local Ask runs on the platform
|
|
488
|
-
* Anthropic key through the proxy, so this call is the only thing that makes
|
|
489
|
-
* the turn billable — the mint-time credit check gates the session, not the
|
|
490
|
-
* spend. Sending `costUsd` rather than a credit count keeps pricing on the
|
|
491
|
-
* server.
|
|
492
|
-
*
|
|
493
|
-
* History is best effort: a failed ingest is reported but never turns a reply
|
|
494
|
-
* the user already read into a failed turn.
|
|
495
|
-
*/
|
|
496
|
-
/**
|
|
497
|
-
* Whether the turn got as far as Anthropic. `costUsd` and `totalTokens` are
|
|
498
|
-
* reported independently by the harness, so either alone means it did.
|
|
499
|
-
*/
|
|
500
|
-
function didReachAnthropic(usage) {
|
|
501
|
-
return usage.costUsd > 0 || usage.totalTokens > 0;
|
|
502
|
-
}
|
|
503
|
-
async function ingestTurn(input) {
|
|
504
|
-
if (input.answer.length === 0 && didReachAnthropic(input.usage) === false) {
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
try {
|
|
508
|
-
await input.api.ai.message.ingest({
|
|
509
|
-
chatUuid: input.chatUuid,
|
|
510
|
-
agentUuid: input.agentUuid,
|
|
511
|
-
userParts: [{ type: "text", text: input.question }],
|
|
512
|
-
// A turn cancelled before it said anything is recorded for its usage
|
|
513
|
-
// alone, so it has no text part rather than an empty one.
|
|
514
|
-
assistantParts: input.answer.length === 0 ? [] : [{ type: "text", text: input.answer }],
|
|
515
|
-
usage: didReachAnthropic(input.usage) === true ? input.usage : undefined,
|
|
516
|
-
cancelled: input.cancelled,
|
|
517
|
-
turnOwnerId: input.turnOwnerId,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
catch (error) {
|
|
521
|
-
if (input.onNotice !== undefined) {
|
|
522
|
-
input.onNotice(`Could not save this turn to the chat history: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* How many times one turn may suspend for approvals. Each round trip is a
|
|
528
|
-
* decision the user made, so this is only a stop for a harness that keeps
|
|
529
|
-
* re-asking for a tool it was already refused.
|
|
530
|
-
*/
|
|
531
|
-
const MAX_APPROVAL_ROUNDS = 64;
|
|
532
|
-
/**
|
|
533
|
-
* Run one turn to completion, answering approval requests along the way.
|
|
534
|
-
*
|
|
535
|
-
* A turn suspends whenever Claude Code wants a tool `permissionMode` does not
|
|
536
|
-
* cover: the stream ends with the approvals still pending, and the turn only
|
|
537
|
-
* finishes once decisions are fed back in. Exported for tests.
|
|
538
|
-
*/
|
|
539
|
-
export async function runPromptTurn(input) {
|
|
540
|
-
const handlers = input.handlers === undefined ? {} : input.handlers;
|
|
541
|
-
const onApproval = handlers.onApproval;
|
|
542
|
-
const accumulator = input.accumulator === undefined
|
|
543
|
-
? createTurnAccumulator()
|
|
544
|
-
: input.accumulator;
|
|
545
|
-
const stallTimeoutMs = input.stallTimeoutMs === undefined
|
|
546
|
-
? TURN_STALL_TIMEOUT_MS
|
|
547
|
-
: input.stallTimeoutMs;
|
|
548
|
-
const toolStallTimeoutMs = input.toolStallTimeoutMs === undefined
|
|
549
|
-
? TURN_TOOL_STALL_TIMEOUT_MS
|
|
550
|
-
: input.toolStallTimeoutMs;
|
|
551
|
-
const assistantChunks = accumulator.chunks;
|
|
552
|
-
const toolCalls = new Map();
|
|
553
|
-
const turnState = {
|
|
554
|
-
toolRanSinceText: false,
|
|
555
|
-
announced: [],
|
|
556
|
-
};
|
|
557
|
-
let stream = await input.driver.start();
|
|
558
|
-
let round = 0;
|
|
559
|
-
for (;;) {
|
|
560
|
-
const approvals = await consumeTurn({
|
|
561
|
-
stream,
|
|
562
|
-
handlers,
|
|
563
|
-
assistantChunks,
|
|
564
|
-
toolCalls,
|
|
565
|
-
usage: accumulator.usage,
|
|
566
|
-
turnState,
|
|
567
|
-
stallTimeoutMs,
|
|
568
|
-
toolStallTimeoutMs,
|
|
569
|
-
onStall: input.onStall,
|
|
570
|
-
});
|
|
571
|
-
if (approvals.length === 0) {
|
|
572
|
-
break;
|
|
573
|
-
}
|
|
574
|
-
if (input.unanswered !== undefined) {
|
|
575
|
-
input.unanswered.push(...approvals);
|
|
576
|
-
}
|
|
577
|
-
round += 1;
|
|
578
|
-
if (round > MAX_APPROVAL_ROUNDS) {
|
|
579
|
-
throw new Error(`Local Claude Code asked for tool approval ${String(MAX_APPROVAL_ROUNDS)} times in one turn without finishing. Stopping the turn.`);
|
|
580
|
-
}
|
|
581
|
-
const continuations = [];
|
|
582
|
-
for (const approval of approvals) {
|
|
583
|
-
// Refused before the user is asked: not their call to make, and a
|
|
584
|
-
// prompt for it could not say what it was granting.
|
|
585
|
-
const denied = isAlwaysDenied(approval.toolName);
|
|
586
|
-
const approved = denied === true
|
|
587
|
-
? false
|
|
588
|
-
: isAutoApproved(approval.toolName) === true
|
|
589
|
-
? true
|
|
590
|
-
: onApproval === undefined
|
|
591
|
-
? false
|
|
592
|
-
: await onApproval(approval);
|
|
593
|
-
// Recorded against the call so the retraction pass below knows it is
|
|
594
|
-
// settled: an approved one is running and must be left alone, a refused
|
|
595
|
-
// one is certainly not and needs no reprieve to find out. Absent when
|
|
596
|
-
// its result already arrived, which takes it off the list.
|
|
597
|
-
const announced = turnState.announced.find((call) => call.toolCallId === approval.toolCallId);
|
|
598
|
-
if (announced !== undefined) {
|
|
599
|
-
announced.status = approved === true ? "approved" : "refused";
|
|
600
|
-
}
|
|
601
|
-
// Answered, so the drain on a later turn must not deny it again.
|
|
602
|
-
if (input.unanswered !== undefined) {
|
|
603
|
-
const at = input.unanswered.indexOf(approval);
|
|
604
|
-
if (at !== -1) {
|
|
605
|
-
input.unanswered.splice(at, 1);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
continuations.push({
|
|
609
|
-
approvalResponse: {
|
|
610
|
-
type: "tool-approval-response",
|
|
611
|
-
approvalId: approval.approvalId,
|
|
612
|
-
approved,
|
|
613
|
-
// Told why, so it does something else rather than asking again
|
|
614
|
-
// until the approval-round cap ends the turn.
|
|
615
|
-
reason: denied === true ? DENIED_TOOL_REASON : undefined,
|
|
616
|
-
},
|
|
617
|
-
toolCall: {
|
|
618
|
-
type: "tool-call",
|
|
619
|
-
toolCallId: approval.toolCallId,
|
|
620
|
-
toolName: approval.toolName,
|
|
621
|
-
input: approval.input,
|
|
622
|
-
},
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
retractAbandonedCalls(turnState.announced, handlers.onActivity);
|
|
626
|
-
stream = await input.driver.continueWithApprovals(continuations);
|
|
627
|
-
}
|
|
628
|
-
const streamed = assistantChunks.join("");
|
|
629
|
-
if (streamed.length > 0) {
|
|
630
|
-
return streamed;
|
|
631
|
-
}
|
|
632
|
-
const fallback = await stream.text;
|
|
633
|
-
if (typeof fallback !== "string" || fallback.length === 0) {
|
|
634
|
-
return "";
|
|
635
|
-
}
|
|
636
|
-
if (handlers.onTextDelta !== undefined) {
|
|
637
|
-
handlers.onTextDelta(fallback);
|
|
638
|
-
}
|
|
639
|
-
return fallback;
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* Retract the calls the caller was told about and will never hear the end of,
|
|
643
|
-
* at the boundary where the answers to this round's approvals are known.
|
|
644
|
-
*
|
|
645
|
-
* Left on screen instead, an abandoned call outlives the work: no result is
|
|
646
|
-
* coming, so no `end` event is either, and the caller keeps showing a tool
|
|
647
|
-
* that stopped existing.
|
|
648
|
-
*
|
|
649
|
-
* `"approved"` is kept, and `"running"` is the one case that cannot be judged
|
|
650
|
-
* yet — the reason this is not a plain sweep. It is either the sibling whose
|
|
651
|
-
* approval request the harness lost (it surfaces one per stream) or a tool
|
|
652
|
-
* Claude Code ran without asking, whose result has simply not arrived. Both
|
|
653
|
-
* look identical here, so it gets one more stream to prove itself: a live one
|
|
654
|
-
* reports its result and leaves this list on its own.
|
|
655
|
-
*
|
|
656
|
-
* Everything else is certain and retracted — `"streaming"` never finished
|
|
657
|
-
* arriving, `"refused"` was told no, and `"abandoning"` already had its
|
|
658
|
-
* stream and stayed silent. Refusals matter especially: a tool
|
|
659
|
-
* `isAlwaysDenied` refuses never reaches `onApproval`, so this is the caller's
|
|
660
|
-
* only notice.
|
|
661
|
-
*
|
|
662
|
-
* The reprieve is for the caller's benefit only — `"abandoning"` is not an
|
|
663
|
-
* executing status, so the stall watchdog keeps its short deadline throughout
|
|
664
|
-
* it. Extending the reprieve to the watchdog would deadlock the very hang it
|
|
665
|
-
* exists to prevent: a dropped sibling would hold the timer off, the stream
|
|
666
|
-
* would stay silent, and the next boundary, the thing that resolves the
|
|
667
|
-
* ambiguity, would never arrive.
|
|
668
|
-
*/
|
|
669
|
-
function retractAbandonedCalls(announced, onActivity) {
|
|
670
|
-
for (const call of [...announced]) {
|
|
671
|
-
if (call.status === "approved") {
|
|
672
|
-
continue;
|
|
673
|
-
}
|
|
674
|
-
if (call.status === "running") {
|
|
675
|
-
call.status = "abandoning";
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
announced.splice(announced.indexOf(call), 1);
|
|
679
|
-
if (onActivity !== undefined) {
|
|
680
|
-
onActivity({
|
|
681
|
-
kind: "dropped",
|
|
682
|
-
toolCallId: call.toolCallId,
|
|
683
|
-
toolName: call.toolName,
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* How long a turn may produce nothing at all while no tool is running.
|
|
690
|
-
*
|
|
691
|
-
* Generous on purpose: this is a deadlock detector, not a latency budget. The
|
|
692
|
-
* clock only runs when nothing is in flight, so the wait is over a turn that
|
|
693
|
-
* is neither working nor talking.
|
|
694
|
-
*
|
|
695
|
-
* `CARGO_ASK_STALL_TIMEOUT_MS` shortens it for the end-to-end tests, which
|
|
696
|
-
* would otherwise have to sit out the real deadline to prove a hang is caught.
|
|
697
|
-
*/
|
|
698
|
-
const TURN_STALL_TIMEOUT_MS = readTimeoutEnv("CARGO_ASK_STALL_TIMEOUT_MS", 120_000);
|
|
699
|
-
/**
|
|
700
|
-
* The same deadline, for a turn that has a tool executing.
|
|
701
|
-
*
|
|
702
|
-
* A tool at work is silent by design — a test run says nothing for as long as
|
|
703
|
-
* it takes — so this cannot be the deadline above. But it cannot be *no*
|
|
704
|
-
* deadline either, which is what it used to be: a tool the user approved and
|
|
705
|
-
* that never reported back left the turn waiting forever, spinner and all,
|
|
706
|
-
* with nothing to do but kill the terminal. That is the shape of every "it got
|
|
707
|
-
* stuck on the tool" report.
|
|
708
|
-
*
|
|
709
|
-
* Twelve minutes, from Claude Code's own ten-minute ceiling on a Bash tool.
|
|
710
|
-
* That is the bound for the common case, not for every case: the ceiling is
|
|
711
|
-
* configurable, and it does not apply to an MCP tool at all. A `mcp__cargo__*`
|
|
712
|
-
* call that genuinely runs longer than this is cut off with its work lost, so
|
|
713
|
-
* `CARGO_ASK_TOOL_STALL_TIMEOUT_MS` raises it for a workspace that does that
|
|
714
|
-
* routinely. The trade is deliberate: a deadline that covers every conceivable
|
|
715
|
-
* tool is indistinguishable from the hang it replaced.
|
|
716
|
-
*
|
|
717
|
-
* Recoverable either way — the session is replaced and the chat, its history
|
|
718
|
-
* and the working copy all survive.
|
|
719
|
-
*/
|
|
720
|
-
const TURN_TOOL_STALL_TIMEOUT_MS = readTimeoutEnv("CARGO_ASK_TOOL_STALL_TIMEOUT_MS", 12 * 60_000);
|
|
721
|
-
function readTimeoutEnv(name, fallbackMs) {
|
|
722
|
-
const raw = process.env[name];
|
|
723
|
-
if (raw === undefined) {
|
|
724
|
-
return fallbackMs;
|
|
725
|
-
}
|
|
726
|
-
const parsed = Number(raw);
|
|
727
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackMs;
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* How long teardown may take before the session is abandoned. Short, because
|
|
731
|
-
* the caller is usually someone who just pressed Ctrl-C.
|
|
732
|
-
*/
|
|
733
|
-
const SESSION_DESTROY_TIMEOUT_MS = 5_000;
|
|
734
|
-
/**
|
|
735
|
-
* How long winding down a previous unfinished turn may take before it is cut
|
|
736
|
-
* off and the question refused.
|
|
737
|
-
*/
|
|
738
|
-
const TURN_DRAIN_TIMEOUT_MS = 30_000;
|
|
739
|
-
/**
|
|
740
|
-
* The previous turn could not be wound down, so this session cannot take
|
|
741
|
-
* another prompt. Raised rather than pressed on with: the harness would either
|
|
742
|
-
* refuse the prompt itself or run it alongside the turn we failed to finish.
|
|
743
|
-
*
|
|
744
|
-
* The caller's recovery is to replace the session — nothing about a wedged
|
|
745
|
-
* runtime gets better by asking it again.
|
|
746
|
-
*/
|
|
747
|
-
export class HarnessTurnStuck extends Error {
|
|
748
|
-
constructor() {
|
|
749
|
-
super("The previous turn could not be wound down, so this coding session cannot take another question.");
|
|
750
|
-
this.name = "HarnessTurnStuck";
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
/**
|
|
754
|
-
* The turn produced nothing for `TURN_STALL_TIMEOUT_MS` while no tool was in
|
|
755
|
-
* flight. Common causes: a step that asked to run several confirmable tools at
|
|
756
|
-
* once (the harness only surfaces one approval), or the Claude Code bridge
|
|
757
|
-
* going silent. Recoverable — replace the session and ask again.
|
|
758
|
-
*/
|
|
759
|
-
export class HarnessTurnStalled extends Error {
|
|
760
|
-
constructor(timeoutMs, whileToolRunning = false) {
|
|
761
|
-
const seconds = String(Math.round(timeoutMs / 1000));
|
|
762
|
-
super(whileToolRunning === true
|
|
763
|
-
? `A tool has been running for ${seconds}s without reporting anything back, which is longer than Claude Code lets any tool run. Treating it as never coming back rather than waiting on it.`
|
|
764
|
-
: `Local Claude Code went quiet for ${seconds}s with no tool running. Often this is a step that asked to run several confirmable tools at once (only the first can be answered), or the coding session itself stalled.`);
|
|
765
|
-
this.name = "HarnessTurnStalled";
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* The Claude Code bridge dropped mid-turn. Same recovery as a stall: the
|
|
770
|
-
* process behind the session is gone, so the next question needs a new one.
|
|
771
|
-
*/
|
|
772
|
-
export function isBridgeClosedError(error) {
|
|
773
|
-
return (error instanceof Error &&
|
|
774
|
-
error.message.includes("bridge closed before the turn finished") === true);
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Fail a turn that has gone silent with no tool running, instead of hanging.
|
|
778
|
-
*
|
|
779
|
-
* `@ai-sdk/harness` surfaces one approval per stream and ends the stream
|
|
780
|
-
* there, so a step that asks to run two tools at once loses the second
|
|
781
|
-
* request: Claude Code keeps waiting for a decision that is never offered,
|
|
782
|
-
* while we keep waiting for output that never comes. Both sides wait forever.
|
|
783
|
-
* `LOCAL_WORKING_COPY_INSTRUCTIONS` asks for one confirmable tool per step to
|
|
784
|
-
* avoid it; this is the backstop for when the model does it anyway.
|
|
785
|
-
*
|
|
786
|
-
* Two deadlines rather than one, because a tool at work is silent by design
|
|
787
|
-
* and a ten-minute test run must not be cut off: `timeoutMs` for a stream with
|
|
788
|
-
* nothing executing, and the far longer `toolTimeoutMs` while a tool is. What
|
|
789
|
-
* there is never again is *no* deadline — a tool that never reports back used
|
|
790
|
-
* to leave the turn on the spinner forever.
|
|
791
|
-
*/
|
|
792
|
-
async function* withStallTimeout(stream, timeoutMs, toolTimeoutMs, isToolInFlight, onStall) {
|
|
793
|
-
if (Number.isFinite(timeoutMs) === false &&
|
|
794
|
-
Number.isFinite(toolTimeoutMs) === false) {
|
|
795
|
-
for await (const part of stream) {
|
|
796
|
-
yield part;
|
|
797
|
-
}
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const iterator = stream[Symbol.asyncIterator]();
|
|
801
|
-
for (;;) {
|
|
802
|
-
const next = iterator.next();
|
|
803
|
-
const running = isToolInFlight();
|
|
804
|
-
const budgetMs = running === true ? toolTimeoutMs : timeoutMs;
|
|
805
|
-
const result = await (Number.isFinite(budgetMs) === false
|
|
806
|
-
? next
|
|
807
|
-
: rejectIfIdle(next, budgetMs, onStall, running));
|
|
808
|
-
if (result.done === true) {
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
yield result.value;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
/**
|
|
815
|
-
* Wait for `work`, but give up after `timeoutMs` rather than blocking a
|
|
816
|
-
* teardown forever. Resolves either way; the caller has already decided that
|
|
817
|
-
* an unfinished result is better than waiting.
|
|
818
|
-
*
|
|
819
|
-
* Exported for tests.
|
|
820
|
-
*/
|
|
821
|
-
export async function settleWithin(work, timeoutMs) {
|
|
822
|
-
let timer;
|
|
823
|
-
try {
|
|
824
|
-
await Promise.race([
|
|
825
|
-
work,
|
|
826
|
-
new Promise((resolve) => {
|
|
827
|
-
timer = setTimeout(resolve, timeoutMs);
|
|
828
|
-
}),
|
|
829
|
-
]);
|
|
830
|
-
}
|
|
831
|
-
finally {
|
|
832
|
-
if (timer !== undefined) {
|
|
833
|
-
clearTimeout(timer);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
async function rejectIfIdle(next, timeoutMs, onStall, whileToolRunning) {
|
|
838
|
-
let timer;
|
|
839
|
-
try {
|
|
840
|
-
return await Promise.race([
|
|
841
|
-
next,
|
|
842
|
-
new Promise((_resolve, reject) => {
|
|
843
|
-
timer = setTimeout(() => {
|
|
844
|
-
// Reject first so Promise.race settles on HarnessTurnStalled, not
|
|
845
|
-
// on an AbortError the interrupt below may race onto `next`.
|
|
846
|
-
reject(new HarnessTurnStalled(timeoutMs, whileToolRunning));
|
|
847
|
-
// Then stop the bridge so teardown is an intentional abort, not a
|
|
848
|
-
// mysterious "bridge closed before the turn finished" after we
|
|
849
|
-
// have already walked away from the stream.
|
|
850
|
-
if (onStall !== undefined) {
|
|
851
|
-
onStall();
|
|
852
|
-
}
|
|
853
|
-
}, timeoutMs);
|
|
854
|
-
}),
|
|
855
|
-
]);
|
|
856
|
-
}
|
|
857
|
-
finally {
|
|
858
|
-
if (timer !== undefined) {
|
|
859
|
-
clearTimeout(timer);
|
|
860
|
-
}
|
|
861
|
-
// On a stall we walk away from `next`; a later failure has no other
|
|
862
|
-
// handler and would come back as an unhandled rejection.
|
|
863
|
-
next.catch(() => undefined);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
/**
|
|
867
|
-
* Claude Code's own bookkeeping. `allow-reads` classifies these as edits
|
|
868
|
-
* because they call a write-shaped tool, but they touch nothing outside the
|
|
869
|
-
* session — prompting for a todo-list update is noise, and Claude Code
|
|
870
|
-
* rewrites its list on every step of any multi-step task.
|
|
871
|
-
*/
|
|
872
|
-
const AUTO_APPROVED_TOOL_NAMES = new Set([
|
|
873
|
-
"TodoWrite",
|
|
874
|
-
"EnterPlanMode",
|
|
875
|
-
"ExitPlanMode",
|
|
876
|
-
"TaskCreate",
|
|
877
|
-
"TaskUpdate",
|
|
878
|
-
"TaskStop",
|
|
879
|
-
"AskUserQuestion",
|
|
880
|
-
]);
|
|
881
|
-
/**
|
|
882
|
-
* Cargo's MCP tools run unattended, `execute_action` included: a turn can
|
|
883
|
-
* spend credits and write to a connected system on its own. That is the
|
|
884
|
-
* deliberate trade for a chat that answers without being nursed, and it is
|
|
885
|
-
* defensible only because this server reaches the workspace with the user's
|
|
886
|
-
* own credentials — it can do nothing they could not do from the CLI.
|
|
887
|
-
*
|
|
888
|
-
* Only this server's tools. Claude Code names them `mcp__<server>__<tool>`,
|
|
889
|
-
* and matching the bare `mcp__` was safe only while Ask owned the config
|
|
890
|
-
* directory: `CLAUDE_CONFIG_DIR` reinstates the user's own MCP servers, whose
|
|
891
|
-
* tools write and shell out with none of the above reasoning behind them.
|
|
892
|
-
* Those go through approval like anything else that touches the machine.
|
|
893
|
-
*
|
|
894
|
-
* Built from the mount key so renaming the server cannot silently widen this.
|
|
895
|
-
*/
|
|
896
|
-
const CARGO_MCP_TOOL_PREFIX = `mcp__${CARGO_MCP_SERVER_NAME}__`;
|
|
897
|
-
/** Exported for tests. */
|
|
898
|
-
export function isAutoApproved(toolName) {
|
|
899
|
-
if (toolName.startsWith(CARGO_MCP_TOOL_PREFIX) === true) {
|
|
900
|
-
return true;
|
|
901
|
-
}
|
|
902
|
-
return AUTO_APPROVED_TOOL_NAMES.has(toolName);
|
|
903
|
-
}
|
|
904
|
-
/**
|
|
905
|
-
* `Agent` spawns a subagent to run a task of its own, and approving one is a
|
|
906
|
-
* different kind of decision from approving a `Bash`: the subagent runs its
|
|
907
|
-
* own tools, can be asked for with `mode: "bypassPermissions"`, and whether
|
|
908
|
-
* its calls come back here for individual approval is not something we control
|
|
909
|
-
* or currently know.
|
|
910
|
-
*
|
|
911
|
-
* The refusal that actually stops it is in `claudeConfig.ts`, as a deny rule
|
|
912
|
-
* in Claude Code's own settings — this never fired, because the approval
|
|
913
|
-
* request it waits for is never sent. The bridge writes `ask` rules only for
|
|
914
|
-
* the tools in its `NATIVE_TOOL_KINDS` map, the subagent tools are not in it,
|
|
915
|
-
* and so `allow-reads` left them running unattended.
|
|
916
|
-
*
|
|
917
|
-
* Kept as the second line: a user who points `CLAUDE_CONFIG_DIR` at their own
|
|
918
|
-
* Claude Code config gets no deny rule written for them, and a future bridge
|
|
919
|
-
* that does surface the approval should find an answer waiting.
|
|
920
|
-
*/
|
|
921
|
-
const DENIED_TOOL_NAMES = new Set(["Agent", "Task"]);
|
|
922
|
-
const DENIED_TOOL_REASON = "Spawning a subagent is not available in this session. Do the work in this turn instead, asking for each tool you need.";
|
|
923
|
-
/** Exported for tests. */
|
|
924
|
-
export function isAlwaysDenied(toolName) {
|
|
925
|
-
return DENIED_TOOL_NAMES.has(toolName);
|
|
926
|
-
}
|
|
927
|
-
/** Tool inputs that name a file the turn is about to change. */
|
|
928
|
-
const FILE_WRITING_TOOLS = new Set([
|
|
929
|
-
"write",
|
|
930
|
-
"edit",
|
|
931
|
-
"Write",
|
|
932
|
-
"Edit",
|
|
933
|
-
"NotebookEdit",
|
|
934
|
-
]);
|
|
935
|
-
/**
|
|
936
|
-
* Drain one turn: text deltas go to the caller, tool calls are remembered so
|
|
937
|
-
* an approval request can be matched back to its call, and pending approvals
|
|
938
|
-
* are returned so the caller can decide and continue the turn.
|
|
939
|
-
*/
|
|
940
|
-
async function consumeTurn(input) {
|
|
941
|
-
const approvals = [];
|
|
942
|
-
const { handlers } = input;
|
|
943
|
-
const pendingFiles = new Map();
|
|
944
|
-
const parts = withStallTimeout(input.stream.fullStream, input.stallTimeoutMs, input.toolStallTimeoutMs, () => input.turnState.announced.some((call) => call.status === "running" || call.status === "approved"), input.onStall);
|
|
945
|
-
for await (const rawPart of parts) {
|
|
946
|
-
if (typeof rawPart !== "object" || rawPart === null) {
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
const part = rawPart;
|
|
950
|
-
if (part["type"] === "error") {
|
|
951
|
-
const error = part["error"];
|
|
952
|
-
throw new Error(error instanceof Error
|
|
953
|
-
? error.message
|
|
954
|
-
: typeof error === "string"
|
|
955
|
-
? error
|
|
956
|
-
: "Local Claude Code turn failed.");
|
|
957
|
-
}
|
|
958
|
-
// Claude Code streams the tool name before the full call is assembled.
|
|
959
|
-
// Surface that early so the spinner is not stuck on "Asking…" while the
|
|
960
|
-
// input is still arriving — and so a later `tool-call` can fill in the
|
|
961
|
-
// summary without looking like a second tool.
|
|
962
|
-
if (part["type"] === "tool-input-start") {
|
|
963
|
-
const toolCallId = part["id"];
|
|
964
|
-
const toolName = part["toolName"];
|
|
965
|
-
if (typeof toolCallId === "string" && typeof toolName === "string") {
|
|
966
|
-
if (input.toolCalls.has(toolCallId) === false) {
|
|
967
|
-
input.toolCalls.set(toolCallId, {
|
|
968
|
-
toolName,
|
|
969
|
-
input: undefined,
|
|
970
|
-
});
|
|
971
|
-
input.turnState.toolRanSinceText = true;
|
|
972
|
-
// Announced, because the spinner is about to show this name and is
|
|
973
|
-
// owed a retraction if the call never happens — but `"streaming"`,
|
|
974
|
-
// not executing. The tool is not running yet, its input is still
|
|
975
|
-
// being generated, and the deltas that carry it are output the
|
|
976
|
-
// watchdog is entitled to expect; standing it down here would turn a
|
|
977
|
-
// bridge that dies mid-input into a hang.
|
|
978
|
-
input.turnState.announced.push({
|
|
979
|
-
toolCallId,
|
|
980
|
-
toolName,
|
|
981
|
-
status: "streaming",
|
|
982
|
-
});
|
|
983
|
-
if (handlers.onActivity !== undefined) {
|
|
984
|
-
handlers.onActivity({
|
|
985
|
-
kind: "start",
|
|
986
|
-
toolCallId,
|
|
987
|
-
toolName,
|
|
988
|
-
summary: "",
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
continue;
|
|
994
|
-
}
|
|
995
|
-
if (part["type"] === "tool-call") {
|
|
996
|
-
const toolCallId = part["toolCallId"];
|
|
997
|
-
const toolName = part["toolName"];
|
|
998
|
-
if (typeof toolCallId === "string" && typeof toolName === "string") {
|
|
999
|
-
input.toolCalls.set(toolCallId, {
|
|
1000
|
-
toolName,
|
|
1001
|
-
input: part["input"],
|
|
1002
|
-
});
|
|
1003
|
-
input.turnState.toolRanSinceText = true;
|
|
1004
|
-
// The full call is assembled, so something is executing it — that is
|
|
1005
|
-
// what stands the stall watchdog down, and it deliberately waits until
|
|
1006
|
-
// here rather than firing at `tool-input-start`.
|
|
1007
|
-
const announced = input.turnState.announced.find((call) => call.toolCallId === toolCallId);
|
|
1008
|
-
if (announced === undefined) {
|
|
1009
|
-
input.turnState.announced.push({
|
|
1010
|
-
toolCallId,
|
|
1011
|
-
toolName,
|
|
1012
|
-
status: "running",
|
|
1013
|
-
});
|
|
1014
|
-
}
|
|
1015
|
-
else {
|
|
1016
|
-
announced.status = "running";
|
|
1017
|
-
}
|
|
1018
|
-
// Re-emitted when `tool-input-start` already announced the name, so
|
|
1019
|
-
// the spinner can pick up the input summary once it is known.
|
|
1020
|
-
if (handlers.onActivity !== undefined) {
|
|
1021
|
-
handlers.onActivity({
|
|
1022
|
-
kind: "start",
|
|
1023
|
-
toolCallId,
|
|
1024
|
-
toolName,
|
|
1025
|
-
summary: summarizeToolInput(part["input"]),
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
const touched = FILE_WRITING_TOOLS.has(toolName) === true
|
|
1029
|
-
? readFilePath(part["input"])
|
|
1030
|
-
: undefined;
|
|
1031
|
-
// Held until the result says the write happened: a denied or failed
|
|
1032
|
-
// edit must not show up under "files changed".
|
|
1033
|
-
if (touched !== undefined) {
|
|
1034
|
-
pendingFiles.set(toolCallId, touched);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
if (part["type"] === "tool-result" || part["type"] === "tool-error") {
|
|
1040
|
-
const toolCallId = part["toolCallId"];
|
|
1041
|
-
const recorded = typeof toolCallId === "string"
|
|
1042
|
-
? input.toolCalls.get(toolCallId)
|
|
1043
|
-
: undefined;
|
|
1044
|
-
const isError = part["type"] === "tool-error";
|
|
1045
|
-
// A preliminary result is progress from a tool that is still running,
|
|
1046
|
-
// so it must not clear the spinner or tell the stall watchdog the tool
|
|
1047
|
-
// is done.
|
|
1048
|
-
const preliminary = part["preliminary"] === true;
|
|
1049
|
-
if (preliminary === false &&
|
|
1050
|
-
typeof toolCallId === "string" &&
|
|
1051
|
-
handlers.onActivity !== undefined) {
|
|
1052
|
-
handlers.onActivity({
|
|
1053
|
-
kind: "end",
|
|
1054
|
-
toolCallId,
|
|
1055
|
-
toolName: recorded === undefined ? "tool" : recorded.toolName,
|
|
1056
|
-
isError,
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
if (typeof toolCallId === "string" && preliminary === false) {
|
|
1060
|
-
// Off the list on the way out. The `end` above is the caller's
|
|
1061
|
-
// retraction, so a later approval round must not announce a second
|
|
1062
|
-
// one — and leaving under its own steam is how a tool that ran unasked
|
|
1063
|
-
// proves it was alive rather than abandoned.
|
|
1064
|
-
const at = input.turnState.announced.findIndex((call) => call.toolCallId === toolCallId);
|
|
1065
|
-
if (at !== -1) {
|
|
1066
|
-
input.turnState.announced.splice(at, 1);
|
|
1067
|
-
}
|
|
1068
|
-
const touched = pendingFiles.get(toolCallId);
|
|
1069
|
-
pendingFiles.delete(toolCallId);
|
|
1070
|
-
if (touched !== undefined &&
|
|
1071
|
-
isError === false &&
|
|
1072
|
-
handlers.onFileTouched !== undefined) {
|
|
1073
|
-
handlers.onFileTouched(touched);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
if (part["type"] === "finish") {
|
|
1079
|
-
// One suspended turn is several streams, each ending with its own
|
|
1080
|
-
// `finish` and its own totals — sum them. Per-step parts are ignored
|
|
1081
|
-
// so the same tokens are not counted twice.
|
|
1082
|
-
addTurnUsage(input.usage, part);
|
|
1083
|
-
continue;
|
|
1084
|
-
}
|
|
1085
|
-
if (part["type"] === "tool-approval-request") {
|
|
1086
|
-
const approval = readApprovalRequest(part, input.toolCalls);
|
|
1087
|
-
if (approval !== undefined) {
|
|
1088
|
-
approvals.push(approval);
|
|
1089
|
-
}
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
if (part["type"] === "text-delta") {
|
|
1093
|
-
const text = typeof part["text"] === "string"
|
|
1094
|
-
? part["text"]
|
|
1095
|
-
: typeof part["delta"] === "string"
|
|
1096
|
-
? part["delta"]
|
|
1097
|
-
: undefined;
|
|
1098
|
-
if (text === undefined || text.length === 0) {
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
// Claude Code narrates, runs tools, then narrates again. Without a
|
|
1102
|
-
// break the two sides of a tool call run into one another mid-sentence.
|
|
1103
|
-
const previous = input.assistantChunks.join("");
|
|
1104
|
-
if (input.turnState.toolRanSinceText === true &&
|
|
1105
|
-
previous.length > 0 &&
|
|
1106
|
-
previous.endsWith("\n") === false) {
|
|
1107
|
-
input.assistantChunks.push("\n\n");
|
|
1108
|
-
if (handlers.onTextDelta !== undefined) {
|
|
1109
|
-
handlers.onTextDelta("\n\n");
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
input.turnState.toolRanSinceText = false;
|
|
1113
|
-
input.assistantChunks.push(text);
|
|
1114
|
-
if (handlers.onTextDelta !== undefined) {
|
|
1115
|
-
handlers.onTextDelta(text);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
return approvals;
|
|
1120
|
-
}
|
|
1121
|
-
/**
|
|
1122
|
-
* Add one stream's `finish` totals to the turn. `totalUsage` is the AI SDK
|
|
1123
|
-
* shape; `costUsd` is what Claude Code reports for the same stream under
|
|
1124
|
-
* `harnessMetadata`, and is what the API prices into credits.
|
|
1125
|
-
*/
|
|
1126
|
-
function addTurnUsage(usage, part) {
|
|
1127
|
-
const costUsd = readClaudeCodeCostUsd(part);
|
|
1128
|
-
if (costUsd !== undefined) {
|
|
1129
|
-
usage.costUsd += costUsd;
|
|
1130
|
-
}
|
|
1131
|
-
const totalUsage = part["totalUsage"];
|
|
1132
|
-
if (typeof totalUsage !== "object" || totalUsage === null) {
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
const totals = totalUsage;
|
|
1136
|
-
usage.totalTokens += readCount(totals["totalTokens"]);
|
|
1137
|
-
usage.inputTokens += readCount(totals["inputTokens"]);
|
|
1138
|
-
usage.outputTokens += readCount(totals["outputTokens"]);
|
|
1139
|
-
const inputDetails = totals["inputTokenDetails"];
|
|
1140
|
-
if (typeof inputDetails === "object" && inputDetails !== null) {
|
|
1141
|
-
const details = inputDetails;
|
|
1142
|
-
usage.cacheReadTokens += readCount(details["cacheReadTokens"]);
|
|
1143
|
-
usage.cacheWriteTokens += readCount(details["cacheWriteTokens"]);
|
|
1144
|
-
}
|
|
1145
|
-
const outputDetails = totals["outputTokenDetails"];
|
|
1146
|
-
if (typeof outputDetails === "object" && outputDetails !== null) {
|
|
1147
|
-
const details = outputDetails;
|
|
1148
|
-
usage.reasoningTokens += readCount(details["reasoningTokens"]);
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
function readCount(value) {
|
|
1152
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
1153
|
-
? value
|
|
1154
|
-
: 0;
|
|
1155
|
-
}
|
|
1156
|
-
/**
|
|
1157
|
-
* What Claude Code says the turn cost, off the `finish` part.
|
|
1158
|
-
*
|
|
1159
|
-
* Two keys, because the value is renamed on its way here: the bridge emits
|
|
1160
|
-
* `harnessMetadata`, and `HarnessAgent` republishes the same object as
|
|
1161
|
-
* `providerMetadata` on the parts this code actually reads. Looking only for
|
|
1162
|
-
* the bridge's name found nothing, so every local turn was ingested at a cost
|
|
1163
|
-
* of zero and priced into no credits at all.
|
|
1164
|
-
*
|
|
1165
|
-
* Exported for tests.
|
|
1166
|
-
*/
|
|
1167
|
-
export function readClaudeCodeCostUsd(part) {
|
|
1168
|
-
for (const key of ["providerMetadata", "harnessMetadata"]) {
|
|
1169
|
-
const metadata = part[key];
|
|
1170
|
-
if (typeof metadata !== "object" || metadata === null) {
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
const claudeCode = metadata["claude-code"];
|
|
1174
|
-
if (typeof claudeCode !== "object" || claudeCode === null) {
|
|
1175
|
-
continue;
|
|
1176
|
-
}
|
|
1177
|
-
const costUsd = claudeCode["costUsd"];
|
|
1178
|
-
if (typeof costUsd === "number" &&
|
|
1179
|
-
Number.isFinite(costUsd) &&
|
|
1180
|
-
costUsd > 0) {
|
|
1181
|
-
return costUsd;
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
return undefined;
|
|
1185
|
-
}
|
|
1186
|
-
/**
|
|
1187
|
-
* The AI SDK part nests the whole `toolCall`; the harness bridge part carries
|
|
1188
|
-
* only `toolCallId`. Accept both and fall back to the recorded call.
|
|
1189
|
-
*/
|
|
1190
|
-
function readApprovalRequest(part, toolCalls) {
|
|
1191
|
-
const approvalId = part["approvalId"];
|
|
1192
|
-
if (typeof approvalId !== "string") {
|
|
1193
|
-
return undefined;
|
|
1194
|
-
}
|
|
1195
|
-
const nested = part["toolCall"];
|
|
1196
|
-
const toolCall = typeof nested === "object" && nested !== null
|
|
1197
|
-
? nested
|
|
1198
|
-
: undefined;
|
|
1199
|
-
const toolCallId = toolCall !== undefined && typeof toolCall["toolCallId"] === "string"
|
|
1200
|
-
? toolCall["toolCallId"]
|
|
1201
|
-
: typeof part["toolCallId"] === "string"
|
|
1202
|
-
? part["toolCallId"]
|
|
1203
|
-
: undefined;
|
|
1204
|
-
if (toolCallId === undefined) {
|
|
1205
|
-
return undefined;
|
|
1206
|
-
}
|
|
1207
|
-
const recorded = toolCalls.get(toolCallId);
|
|
1208
|
-
const toolName = toolCall !== undefined && typeof toolCall["toolName"] === "string"
|
|
1209
|
-
? toolCall["toolName"]
|
|
1210
|
-
: recorded !== undefined
|
|
1211
|
-
? recorded.toolName
|
|
1212
|
-
: "tool";
|
|
1213
|
-
const toolInput = toolCall !== undefined && "input" in toolCall
|
|
1214
|
-
? toolCall["input"]
|
|
1215
|
-
: recorded !== undefined
|
|
1216
|
-
? recorded.input
|
|
1217
|
-
: undefined;
|
|
1218
|
-
return {
|
|
1219
|
-
approvalId,
|
|
1220
|
-
toolCallId,
|
|
1221
|
-
toolName,
|
|
1222
|
-
summary: summarizeToolInput(toolInput),
|
|
1223
|
-
input: toolInput,
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
/** Fields worth showing first when asking to run a tool. */
|
|
1227
|
-
const TOOL_INPUT_PREVIEW_KEYS = [
|
|
1228
|
-
"command",
|
|
1229
|
-
"file_path",
|
|
1230
|
-
"path",
|
|
1231
|
-
"pattern",
|
|
1232
|
-
"url",
|
|
1233
|
-
];
|
|
1234
|
-
const TOOL_SUMMARY_MAX_LENGTH = 160;
|
|
1235
|
-
/**
|
|
1236
|
-
* The bridge sends tool input as a JSON string, while the AI SDK part carries
|
|
1237
|
-
* the parsed object. Normalize to a record when the shape allows it.
|
|
1238
|
-
*/
|
|
1239
|
-
function readToolInputRecord(toolInput) {
|
|
1240
|
-
if (typeof toolInput === "string") {
|
|
1241
|
-
try {
|
|
1242
|
-
const parsed = JSON.parse(toolInput);
|
|
1243
|
-
return typeof parsed === "object" && parsed !== null
|
|
1244
|
-
? parsed
|
|
1245
|
-
: undefined;
|
|
1246
|
-
}
|
|
1247
|
-
catch {
|
|
1248
|
-
return undefined;
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
return typeof toolInput === "object" && toolInput !== null
|
|
1252
|
-
? toolInput
|
|
1253
|
-
: undefined;
|
|
1254
|
-
}
|
|
1255
|
-
function readFilePath(toolInput) {
|
|
1256
|
-
const record = readToolInputRecord(toolInput);
|
|
1257
|
-
if (record === undefined) {
|
|
1258
|
-
return undefined;
|
|
1259
|
-
}
|
|
1260
|
-
const filePath = record["file_path"];
|
|
1261
|
-
return typeof filePath === "string" && filePath.length > 0
|
|
1262
|
-
? filePath
|
|
1263
|
-
: undefined;
|
|
1264
|
-
}
|
|
1265
|
-
function summarizeToolInput(toolInput) {
|
|
1266
|
-
const record = readToolInputRecord(toolInput);
|
|
1267
|
-
if (record === undefined) {
|
|
1268
|
-
return typeof toolInput === "string" ? truncate(toolInput) : "";
|
|
1269
|
-
}
|
|
1270
|
-
for (const key of TOOL_INPUT_PREVIEW_KEYS) {
|
|
1271
|
-
const value = record[key];
|
|
1272
|
-
if (typeof value === "string" && value.length > 0) {
|
|
1273
|
-
return truncate(value);
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
return truncate(JSON.stringify(record));
|
|
1277
|
-
}
|
|
1278
|
-
function truncate(value) {
|
|
1279
|
-
const flat = value.replace(/\s+/g, " ").trim();
|
|
1280
|
-
return flat.length > TOOL_SUMMARY_MAX_LENGTH
|
|
1281
|
-
? `${flat.slice(0, TOOL_SUMMARY_MAX_LENGTH - 1)}…`
|
|
1282
|
-
: flat;
|
|
1283
|
-
}
|
|
1284
|
-
async function withMutedCredentialBrokerWarning(run) {
|
|
1285
|
-
const originalWarn = console.warn;
|
|
1286
|
-
console.warn = (...args) => {
|
|
1287
|
-
const first = args[0];
|
|
1288
|
-
if (typeof first === "string" &&
|
|
1289
|
-
first.includes("credential brokering does not work")) {
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
originalWarn.apply(console, args);
|
|
1293
|
-
};
|
|
1294
|
-
try {
|
|
1295
|
-
return await run();
|
|
1296
|
-
}
|
|
1297
|
-
finally {
|
|
1298
|
-
console.warn = originalWarn;
|
|
1299
|
-
}
|
|
1300
|
-
}
|