getbotons 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { cliTurn } from './cli-turn.ts';
|
|
2
|
+
import { CLI_NAMES } from '../../../shared/legacy-names.mjs';
|
|
3
|
+
import { prepareCliHomes } from '../cli-homes.ts';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import type { TurnStep } from '../../../shared/types.ts';
|
|
9
|
+
import { totalTokens } from '../../../shared/util.ts';
|
|
10
|
+
import { INSTRUCTION_FILES, instructionPaths } from '../instructions.ts';
|
|
11
|
+
import { log } from '../log.ts';
|
|
12
|
+
import { shorten } from '../util.ts';
|
|
13
|
+
import { type CliCheck, CliChecker, terminalVersion, claudeWorkspaceProblem, checkCli, cliStatus, NOT_CHECKED, parseClaudeLogin } from './cli-check.ts';
|
|
14
|
+
import { classifyFailure } from './failures.ts';
|
|
15
|
+
import { agentEnv, failureMessage, jsonLines, runJsonl, withExit } from './process.ts';
|
|
16
|
+
import {
|
|
17
|
+
type McpStartup,
|
|
18
|
+
type Provider,
|
|
19
|
+
ProviderError,
|
|
20
|
+
type ProviderStatus,
|
|
21
|
+
step,
|
|
22
|
+
type TurnMcpServer,
|
|
23
|
+
type ToolObservation,
|
|
24
|
+
type TurnRequest,
|
|
25
|
+
type TurnResult,
|
|
26
|
+
type TurnUsage,
|
|
27
|
+
} from './types.ts';
|
|
28
|
+
|
|
29
|
+
const MODELS = [
|
|
30
|
+
{ id: 'sonnet', label: 'Sonnet (latest)' },
|
|
31
|
+
{ id: 'opus', label: 'Opus (latest)' },
|
|
32
|
+
{ id: 'fable', label: 'Fable (latest)' },
|
|
33
|
+
{ id: 'haiku', label: 'Haiku (latest)' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function describeTool(name: string, input: any): string {
|
|
37
|
+
switch (name) {
|
|
38
|
+
case 'Bash':
|
|
39
|
+
return `Running \`${shorten(String(input?.command ?? ''), 70)}\``;
|
|
40
|
+
case 'Read':
|
|
41
|
+
return `Reading ${basename(String(input?.file_path ?? 'a file'))}`;
|
|
42
|
+
case 'Edit':
|
|
43
|
+
case 'MultiEdit':
|
|
44
|
+
case 'Write':
|
|
45
|
+
case 'NotebookEdit':
|
|
46
|
+
return `Editing ${basename(String(input?.file_path ?? input?.notebook_path ?? 'a file'))}`;
|
|
47
|
+
case 'Glob':
|
|
48
|
+
case 'Grep':
|
|
49
|
+
return 'Searching files';
|
|
50
|
+
case 'WebSearch':
|
|
51
|
+
return `Searching the web for “${shorten(String(input?.query ?? ''), 50)}”`;
|
|
52
|
+
case 'WebFetch':
|
|
53
|
+
try {
|
|
54
|
+
return `Reading ${new URL(String(input?.url)).host}`;
|
|
55
|
+
} catch {
|
|
56
|
+
return 'Reading a web page';
|
|
57
|
+
}
|
|
58
|
+
case 'TodoWrite':
|
|
59
|
+
return 'Planning';
|
|
60
|
+
case 'Task':
|
|
61
|
+
case 'Agent':
|
|
62
|
+
return 'Delegating to a helper';
|
|
63
|
+
default:
|
|
64
|
+
return `Using ${name.replace(/^mcp__/, '').replace(/__/g, ' ')}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toolStep(name: string, input: any): TurnStep | null {
|
|
69
|
+
const label = describeTool(name, input);
|
|
70
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
71
|
+
switch (name) {
|
|
72
|
+
case 'Bash':
|
|
73
|
+
return step('command', label, str(input?.command));
|
|
74
|
+
case 'Read':
|
|
75
|
+
return step('read', label, str(input?.file_path));
|
|
76
|
+
case 'Edit':
|
|
77
|
+
case 'MultiEdit':
|
|
78
|
+
case 'Write':
|
|
79
|
+
return step('edit', label, str(input?.file_path));
|
|
80
|
+
case 'NotebookEdit':
|
|
81
|
+
return step('edit', label, str(input?.notebook_path));
|
|
82
|
+
case 'Glob':
|
|
83
|
+
case 'Grep':
|
|
84
|
+
return step('search', label, [str(input?.pattern), str(input?.path)].filter(Boolean).join(' in '));
|
|
85
|
+
case 'WebSearch':
|
|
86
|
+
return step('web', label, str(input?.query));
|
|
87
|
+
case 'WebFetch':
|
|
88
|
+
return step('web', label, str(input?.url));
|
|
89
|
+
case 'TodoWrite':
|
|
90
|
+
return null;
|
|
91
|
+
default:
|
|
92
|
+
return step('tool', label, JSON.stringify(input ?? {}));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Scheduling, messaging and app tools: Botons has its own. */
|
|
97
|
+
export const CLAUDE_DISALLOWED_TOOLS = [
|
|
98
|
+
'CronCreate',
|
|
99
|
+
'CronDelete',
|
|
100
|
+
'CronList',
|
|
101
|
+
'ScheduleWakeup',
|
|
102
|
+
'RemoteTrigger',
|
|
103
|
+
'PushNotification',
|
|
104
|
+
'SendMessage',
|
|
105
|
+
'ListAgents',
|
|
106
|
+
'SendToProject',
|
|
107
|
+
'SendToProjectContext',
|
|
108
|
+
'Workflow',
|
|
109
|
+
'DesignSync',
|
|
110
|
+
'ReportFindings',
|
|
111
|
+
'EnterWorktree',
|
|
112
|
+
'ExitWorktree',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** The command a turn runs to reach the server, and its deprecated names (shared/legacy-names.mjs). */
|
|
116
|
+
const CLI_COMMANDS = ['botons', ...CLI_NAMES];
|
|
117
|
+
|
|
118
|
+
// `botons mcp`'s permission prompt tool asks the owner. The model can't call it itself (--disallowedTools).
|
|
119
|
+
const APPROVAL_SERVER = 'botons';
|
|
120
|
+
const APPROVE_TOOL = `mcp__${APPROVAL_SERVER}__approve`;
|
|
121
|
+
|
|
122
|
+
/** Secrets included. `botons mcp` goes last, so a connector of the same name can't stand in for it. */
|
|
123
|
+
export function claudeMcpConfig(servers: TurnMcpServer[], askFirst?: TurnRequest['askFirst']): string {
|
|
124
|
+
const out: Record<string, unknown> = {};
|
|
125
|
+
for (const s of servers) {
|
|
126
|
+
if (s.kind === 'stdio') out[s.name] = { type: 'stdio', command: s.command, args: s.args, env: s.env };
|
|
127
|
+
else out[s.name] = { type: s.sse ? 'sse' : 'http', url: s.url, headers: s.headers };
|
|
128
|
+
}
|
|
129
|
+
if (askFirst) out[APPROVAL_SERVER] = { type: 'stdio', command: askFirst.command, args: askFirst.args };
|
|
130
|
+
return JSON.stringify({ mcpServers: out });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function claudeMcpRule(serverName: string): string {
|
|
134
|
+
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** "//" means from the filesystem root. */
|
|
138
|
+
const rulePath = (abs: string) => `/${abs}`;
|
|
139
|
+
/** No glob or rule syntax. Every private path still goes in the sandbox's denyRead. */
|
|
140
|
+
const plainPath = (abs: string) => !/[()*?[\]{}\\]/.test(abs);
|
|
141
|
+
|
|
142
|
+
function settings(req: Pick<TurnRequest, 'access' | 'privatePaths' | 'cwd' | 'sharedDirs' | 'askFirst' | 'protectInstructions'>): Record<string, unknown> {
|
|
143
|
+
const base = { autoMemoryEnabled: false, disableAllHooks: true };
|
|
144
|
+
const ask = req.askFirst?.ask.length ? { ask: req.askFirst.ask } : {};
|
|
145
|
+
if (req.access === 'host') return req.askFirst?.ask.length ? { ...base, permissions: ask } : base;
|
|
146
|
+
// Workspace: the .claude folders can't be written, so a Boton can't give itself (or another Boton) settings or
|
|
147
|
+
// hooks; nor, in a sandboxed turn, the instruction files (in subfolders too) every later turn would load.
|
|
148
|
+
const shared = req.sharedDirs ?? [];
|
|
149
|
+
const settingsDirs = [req.cwd, ...shared].map((d) => join(d, '.claude'));
|
|
150
|
+
const instructions = req.protectInstructions ? instructionPaths(req.cwd) : [];
|
|
151
|
+
const nested = req.protectInstructions && plainPath(req.cwd) ? INSTRUCTION_FILES.map((name) => `Edit(${rulePath(req.cwd)}/**/${name})`) : [];
|
|
152
|
+
return {
|
|
153
|
+
...base,
|
|
154
|
+
permissions: {
|
|
155
|
+
deny: [
|
|
156
|
+
...req.privatePaths.filter(plainPath).flatMap((p) => [`Read(${rulePath(p)})`, `Read(${rulePath(p)}/**)`]),
|
|
157
|
+
...settingsDirs.map((d) => `Edit(${rulePath(d)}/**)`),
|
|
158
|
+
...instructions.filter(plainPath).flatMap((p) => [`Edit(${rulePath(p)})`, `Edit(${rulePath(p)}/**)`]),
|
|
159
|
+
...nested,
|
|
160
|
+
],
|
|
161
|
+
...ask,
|
|
162
|
+
...(req.askFirst?.allow.length ? { allow: req.askFirst.allow } : {}),
|
|
163
|
+
},
|
|
164
|
+
sandbox: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
autoAllowBashIfSandboxed: true,
|
|
167
|
+
allowUnsandboxedCommands: false,
|
|
168
|
+
// The botons CLI (and its deprecated names, for one release) reaches the server on 127.0.0.1 as this turn.
|
|
169
|
+
excludedCommands: CLI_COMMANDS.flatMap((n) => [n, `${n} *`]),
|
|
170
|
+
filesystem: { denyRead: req.privatePaths, ...(shared.length ? { allowWrite: shared } : {}), denyWrite: [...settingsDirs, ...instructions] },
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Workspace: anything not allowed here is denied (or, with Ask first rules, goes to the approve tool). */
|
|
176
|
+
function accessArgs(access: TurnRequest['access'], servers: TurnMcpServer[], asks: boolean): string[] {
|
|
177
|
+
if (access === 'host') return ['--dangerously-skip-permissions'];
|
|
178
|
+
return [
|
|
179
|
+
'--permission-mode',
|
|
180
|
+
'acceptEdits',
|
|
181
|
+
'--permission-prompts',
|
|
182
|
+
asks ? 'host' : 'none',
|
|
183
|
+
'--allowedTools',
|
|
184
|
+
...CLI_COMMANDS.flatMap((n) => [`Bash(${n}:*)`, `Bash(${n} *)`]),
|
|
185
|
+
'WebSearch',
|
|
186
|
+
'WebFetch',
|
|
187
|
+
...servers.map((s) => claudeMcpRule(s.name)),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const count = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
192
|
+
|
|
193
|
+
function messageUsage(u: any): TurnUsage {
|
|
194
|
+
return {
|
|
195
|
+
inputTokens: count(u?.input_tokens),
|
|
196
|
+
outputTokens: count(u?.output_tokens),
|
|
197
|
+
cacheReadTokens: count(u?.cache_read_input_tokens),
|
|
198
|
+
cacheWriteTokens: count(u?.cache_creation_input_tokens),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const anyTokens = (u: TurnUsage) => totalTokens(u) > 0;
|
|
203
|
+
const noTokens = (): TurnUsage => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
204
|
+
const combine = (a: TurnUsage, b: TurnUsage, f: (x: number, y: number) => number): TurnUsage => ({
|
|
205
|
+
inputTokens: f(a.inputTokens, b.inputTokens),
|
|
206
|
+
outputTokens: f(a.outputTokens, b.outputTokens),
|
|
207
|
+
cacheReadTokens: f(a.cacheReadTokens, b.cacheReadTokens),
|
|
208
|
+
cacheWriteTokens: f(a.cacheWriteTokens, b.cacheWriteTokens),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** A result's `modelUsage` added up over its models, and the model that wrote the most. */
|
|
212
|
+
function modelTotals(result: any): { usage: TurnUsage; top: string | null } {
|
|
213
|
+
const entries = result?.modelUsage && typeof result.modelUsage === 'object' ? Object.entries(result.modelUsage as Record<string, any>) : [];
|
|
214
|
+
const usage = noTokens();
|
|
215
|
+
let top: { model: string; out: number } | null = null;
|
|
216
|
+
for (const [name, m] of entries) {
|
|
217
|
+
usage.inputTokens += count(m?.inputTokens);
|
|
218
|
+
usage.outputTokens += count(m?.outputTokens);
|
|
219
|
+
usage.cacheReadTokens += count(m?.cacheReadInputTokens);
|
|
220
|
+
usage.cacheWriteTokens += count(m?.cacheCreationInputTokens);
|
|
221
|
+
if (!top || count(m?.outputTokens) > top.out) top = { model: name, out: count(m?.outputTokens) };
|
|
222
|
+
}
|
|
223
|
+
return { usage, top: top?.model ?? null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface SessionTotals {
|
|
227
|
+
tokens: number;
|
|
228
|
+
costUsd: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function sessionTotals(result: any): SessionTotals {
|
|
232
|
+
const cost = result?.total_cost_usd;
|
|
233
|
+
return { tokens: totalTokens(modelTotals(result).usage), costUsd: typeof cost === 'number' && Number.isFinite(cost) && cost > 0 ? cost : 0 };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The result's `usage` is this process's (all zeros on failure; then `seen` counts). Its `modelUsage` and
|
|
238
|
+
* `total_cost_usd` are the session's running totals, carried over by --resume: so the cost is total_cost_usd when
|
|
239
|
+
* nothing was carried over, else the difference from `before` when exactly that was carried over, else unknown.
|
|
240
|
+
*/
|
|
241
|
+
export function claudeUsage(result: any, model: string | null, opts: { resumed: boolean; seen?: TurnUsage | null; before?: SessionTotals | null }): TurnUsage | null {
|
|
242
|
+
const { usage: all, top } = modelTotals(result);
|
|
243
|
+
const own = messageUsage(result?.usage);
|
|
244
|
+
let usage: TurnUsage = anyTokens(own) ? own : opts.seen ? { ...opts.seen } : noTokens();
|
|
245
|
+
if (!opts.resumed && anyTokens(all)) usage = all;
|
|
246
|
+
const total = sessionTotals(result);
|
|
247
|
+
if (total.costUsd > 0) {
|
|
248
|
+
const carried = total.tokens - totalTokens(usage);
|
|
249
|
+
if (carried === 0) usage.costUsd = total.costUsd;
|
|
250
|
+
else if (opts.before && carried === opts.before.tokens) {
|
|
251
|
+
// Rounded: a difference of two floats.
|
|
252
|
+
const cost = Math.round((total.costUsd - opts.before.costUsd) * 1e12) / 1e12;
|
|
253
|
+
if (cost > 0) usage.costUsd = cost;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (typeof result?.num_turns === 'number') usage.numTurns = result.num_turns;
|
|
257
|
+
const ran = model ?? top;
|
|
258
|
+
if (ran) usage.model = ran;
|
|
259
|
+
return anyTokens(usage) || ran ? usage : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const MISSING_SESSION = 'No conversation found with session ID';
|
|
263
|
+
|
|
264
|
+
const asObject = (v: unknown): Record<string, unknown> | null => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null);
|
|
265
|
+
|
|
266
|
+
/** A JSON object from a text block (MCP servers repeat structuredContent as JSON text); never prose. */
|
|
267
|
+
function jsonText(content: unknown): Record<string, unknown> | null {
|
|
268
|
+
const blocks = Array.isArray(content) ? content : typeof content === 'string' ? [{ type: 'text', text: content }] : [];
|
|
269
|
+
for (const b of blocks) {
|
|
270
|
+
const text = (b as { type?: unknown; text?: unknown })?.type === 'text' ? (b as { text: unknown }).text : null;
|
|
271
|
+
if (typeof text !== 'string' || !text.trim().startsWith('{') || text.length > 200_000) continue;
|
|
272
|
+
try {
|
|
273
|
+
const v = asObject(JSON.parse(text));
|
|
274
|
+
if (v) return v;
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Which of the turn's servers an `mcp__<server>__<tool>` name is (the longest name that fits), and the tool. */
|
|
281
|
+
export function claudeMcpTool(name: string, servers: string[]): { server: string; tool: string } | null {
|
|
282
|
+
let best: { server: string; tool: string } | null = null;
|
|
283
|
+
for (const s of servers) {
|
|
284
|
+
const prefix = `${claudeMcpRule(s)}__`;
|
|
285
|
+
if (name.startsWith(prefix) && name.length > prefix.length && (!best || s.length > best.server.length)) best = { server: s, tool: name.slice(prefix.length) };
|
|
286
|
+
}
|
|
287
|
+
return best;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A tool_result for one of the turn's MCP calls, as an observation. `raw` is the event's tool_use_result. */
|
|
291
|
+
export function claudeToolResult(block: any, call: { server: string; tool: string }, raw: unknown): ToolObservation {
|
|
292
|
+
const r = asObject(raw);
|
|
293
|
+
const structured = asObject(r?.structuredContent) ?? jsonText(Array.isArray(raw) ? raw : r?.content) ?? jsonText(block?.content);
|
|
294
|
+
return { callId: String(block?.tool_use_id ?? ''), server: call.server, tool: call.tool, ok: block?.is_error !== true && r?.isError !== true, structured };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Claude Code's words for a server it didn't connect, as the owner and the Boton read them. */
|
|
298
|
+
function notStarted(status: string): string {
|
|
299
|
+
switch (status) {
|
|
300
|
+
case 'failed':
|
|
301
|
+
return 'Claude Code could not start it';
|
|
302
|
+
case 'needs-auth':
|
|
303
|
+
return 'Claude Code could not start it: the server refused its sign-in';
|
|
304
|
+
case 'pending':
|
|
305
|
+
return 'it was still starting when the turn began, so the turn may not have had its tools';
|
|
306
|
+
case 'disabled':
|
|
307
|
+
return 'Claude Code had it turned off';
|
|
308
|
+
default:
|
|
309
|
+
return /^[a-z-]{1,24}$/.test(status) ? `Claude Code reported it ${status}, not connected` : 'Claude Code did not connect it';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The turn's servers as its init event shows them (`mcp_servers` with each one's status, and the `mcp__<server>__…`
|
|
315
|
+
* names in `tools`), with a server one of the turn's tool calls ran on (a result that isn't an error) counted as
|
|
316
|
+
* started whatever init said. A call that failed or never got its result leaves init's word on the server.
|
|
317
|
+
*/
|
|
318
|
+
export function claudeMcpStartup(init: any, servers: string[], called: Set<string>): McpStartup[] {
|
|
319
|
+
const listed: unknown[] = Array.isArray(init?.mcp_servers) ? init.mcp_servers : [];
|
|
320
|
+
const tools: string[] = Array.isArray(init?.tools) ? init.tools.filter((t: unknown): t is string => typeof t === 'string') : [];
|
|
321
|
+
return servers.map((name) => {
|
|
322
|
+
const own = tools.flatMap((t) => {
|
|
323
|
+
const m = claudeMcpTool(t, servers);
|
|
324
|
+
return m?.server === name ? [m.tool] : [];
|
|
325
|
+
});
|
|
326
|
+
const entry = listed.find((e) => (e as { name?: unknown })?.name === name) as { status?: unknown } | undefined;
|
|
327
|
+
const status = typeof entry?.status === 'string' ? entry.status : null;
|
|
328
|
+
if (called.has(name)) return { name, started: true, tools: own };
|
|
329
|
+
if (!entry) return { name, started: false, tools: [], reason: 'Claude Code did not load it' };
|
|
330
|
+
if (status !== 'connected') return { name, started: false, tools: own, reason: notStarted(status ?? '') };
|
|
331
|
+
if (!own.length) return { name, started: false, tools: [], reason: 'Claude Code connected to it, but it listed no tools' };
|
|
332
|
+
return { name, started: true, tools: own };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* None of the user's Claude Code setup at any access level (user settings, MCP servers, skills, slash commands);
|
|
338
|
+
* the login still comes from it (--bare would not use it). `mcpConfig`: a file's path when there are servers.
|
|
339
|
+
*/
|
|
340
|
+
export function claudeArgs(
|
|
341
|
+
req: Pick<TurnRequest, 'systemPrompt' | 'model' | 'effort' | 'sessionId' | 'access' | 'privatePaths' | 'cwd' | 'mcpServers' | 'sharedDirs' | 'askFirst' | 'protectInstructions' | 'toolFree'>,
|
|
342
|
+
mcpConfig = claudeMcpConfig([], req.askFirst),
|
|
343
|
+
): string[] {
|
|
344
|
+
if (req.toolFree) return toolFreeArgs(req);
|
|
345
|
+
const asks = !!req.askFirst;
|
|
346
|
+
return [
|
|
347
|
+
'-p',
|
|
348
|
+
'--output-format',
|
|
349
|
+
'stream-json',
|
|
350
|
+
'--verbose',
|
|
351
|
+
'--include-partial-messages',
|
|
352
|
+
'--append-system-prompt',
|
|
353
|
+
req.systemPrompt,
|
|
354
|
+
'--system-prompt-snapshot',
|
|
355
|
+
'off',
|
|
356
|
+
...(req.model ? ['--model', req.model] : []),
|
|
357
|
+
// Wins over the settings files; CLAUDE_CODE_EFFORT_LEVEL would win over it, but agentEnv never passes that on.
|
|
358
|
+
...(req.effort ? ['--effort', req.effort] : []),
|
|
359
|
+
...(req.sessionId ? ['--resume', req.sessionId] : []),
|
|
360
|
+
'--setting-sources',
|
|
361
|
+
'project,local',
|
|
362
|
+
'--strict-mcp-config',
|
|
363
|
+
'--mcp-config',
|
|
364
|
+
mcpConfig,
|
|
365
|
+
'--disable-slash-commands',
|
|
366
|
+
'--disallowedTools',
|
|
367
|
+
...CLAUDE_DISALLOWED_TOOLS,
|
|
368
|
+
...(asks ? [APPROVE_TOOL] : []),
|
|
369
|
+
'--settings',
|
|
370
|
+
JSON.stringify(settings(req)),
|
|
371
|
+
...(req.sharedDirs ?? []).flatMap((d) => ['--add-dir', d]),
|
|
372
|
+
...(asks ? ['--permission-prompt-tool', APPROVE_TOOL] : []),
|
|
373
|
+
...accessArgs(req.access, req.mcpServers ?? [], asks),
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A reply with no tools (Claude Code CLI reference): `--tools ""` takes every built-in tool away, `--disallowedTools
|
|
379
|
+
* "*"` removes every tool from the model's context (MCP tools included), no MCP server is given, skills and commands
|
|
380
|
+
* are off, and a prompt for a tool is denied, never asked. These are flags of this process, so they hold on the
|
|
381
|
+
* resumed session too; a CLI that doesn't know one of them fails the turn rather than run it with tools. The same
|
|
382
|
+
* session, system prompt, model and effort as the Boton's turns, so it resumes the session they keep.
|
|
383
|
+
*/
|
|
384
|
+
function toolFreeArgs(req: Pick<TurnRequest, 'systemPrompt' | 'model' | 'effort' | 'sessionId' | 'access' | 'privatePaths' | 'cwd'>): string[] {
|
|
385
|
+
return [
|
|
386
|
+
'-p',
|
|
387
|
+
'--output-format',
|
|
388
|
+
'stream-json',
|
|
389
|
+
'--verbose',
|
|
390
|
+
'--include-partial-messages',
|
|
391
|
+
'--append-system-prompt',
|
|
392
|
+
req.systemPrompt,
|
|
393
|
+
'--system-prompt-snapshot',
|
|
394
|
+
'off',
|
|
395
|
+
...(req.model ? ['--model', req.model] : []),
|
|
396
|
+
...(req.effort ? ['--effort', req.effort] : []),
|
|
397
|
+
...(req.sessionId ? ['--resume', req.sessionId] : []),
|
|
398
|
+
'--setting-sources',
|
|
399
|
+
'project,local',
|
|
400
|
+
'--strict-mcp-config',
|
|
401
|
+
'--mcp-config',
|
|
402
|
+
claudeMcpConfig([]),
|
|
403
|
+
'--disable-slash-commands',
|
|
404
|
+
'--tools',
|
|
405
|
+
'',
|
|
406
|
+
'--disallowedTools',
|
|
407
|
+
'*',
|
|
408
|
+
'--settings',
|
|
409
|
+
JSON.stringify(settings({ access: 'workspace', privatePaths: req.privatePaths, cwd: req.cwd, sharedDirs: [], protectInstructions: true })),
|
|
410
|
+
'--permission-mode',
|
|
411
|
+
'default',
|
|
412
|
+
'--permission-prompts',
|
|
413
|
+
'none',
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** So a leak of the user's setup into Botons shows in the log. */
|
|
418
|
+
function logNewSession(ev: any) {
|
|
419
|
+
const names = (list: unknown) => (Array.isArray(list) ? list.map((x: any) => (typeof x === 'string' ? x : String(x?.name ?? '?'))) : []);
|
|
420
|
+
const skills = names(ev.skills);
|
|
421
|
+
log.info('new claude session', {
|
|
422
|
+
provider: 'claude-cli',
|
|
423
|
+
sessionId: ev.session_id,
|
|
424
|
+
model: ev.model,
|
|
425
|
+
tools: names(ev.tools),
|
|
426
|
+
mcpServers: names(ev.mcp_servers),
|
|
427
|
+
...(skills.length ? { skills } : {}),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export class ClaudeCliProvider implements Provider {
|
|
432
|
+
id = 'claude-cli';
|
|
433
|
+
label = 'Claude (local CLI)';
|
|
434
|
+
cli = 'claude' as const;
|
|
435
|
+
askFirst = true;
|
|
436
|
+
toolFree = true;
|
|
437
|
+
#bin: string;
|
|
438
|
+
#check: CliChecker;
|
|
439
|
+
/** Botons' own copy (managed-clis.ts), not the system's. */
|
|
440
|
+
#managed: boolean;
|
|
441
|
+
/** Each session's running totals after its last turn here, for the next turn's cost. */
|
|
442
|
+
#totals = new Map<string, SessionTotals>();
|
|
443
|
+
|
|
444
|
+
constructor(bin: string, managed = false) {
|
|
445
|
+
this.#bin = bin;
|
|
446
|
+
this.#managed = managed;
|
|
447
|
+
this.#check = new CliChecker(this.id, async () => { const c = await checkCli(this.#bin, ['auth', 'status'], parseClaudeLogin); return { ...c, workspaceProblem: c.installed ? claudeWorkspaceProblem() : null, terminal: managed && c.installed ? await terminalVersion('claude-cli', this.#bin) : null }; });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async info(): Promise<ProviderStatus> {
|
|
451
|
+
return this.#status(await this.#check.get());
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
snapshot(): ProviderStatus {
|
|
455
|
+
return this.#status(this.#check.peek());
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#status(c: CliCheck | null): ProviderStatus {
|
|
459
|
+
return { id: this.id, label: this.label, kind: 'cli', ...cliStatus(c ?? NOT_CHECKED), ...(c ? {} : { checkedAt: null }), copy: { managed: this.#managed, terminal: c?.terminal ?? null }, models: MODELS, defaultModel: 'sonnet', askFirst: true };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
recheck(): void {
|
|
463
|
+
this.#check.invalidate();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async runTurn(req: TurnRequest): Promise<TurnResult> {
|
|
467
|
+
prepareCliHomes();
|
|
468
|
+
if (req.toolFree && (req.mcpServers?.length || req.askFirst)) throw new Error('A tool-free turn takes no MCP servers or Ask first rules');
|
|
469
|
+
let sessionId: string | null = req.sessionId;
|
|
470
|
+
let final: any = null;
|
|
471
|
+
let lastText = '';
|
|
472
|
+
// The model did something, so the session was found even if the run then failed.
|
|
473
|
+
let activity = false;
|
|
474
|
+
// The session's model, and each API response's usage by message id, for a turn that ends without a result.
|
|
475
|
+
let model: string | null = null;
|
|
476
|
+
const responses = new Map<string, TurnUsage>();
|
|
477
|
+
const saw = (id: string, u: any) => {
|
|
478
|
+
const prev = responses.get(id);
|
|
479
|
+
responses.set(id, prev ? combine(prev, messageUsage(u), Math.max) : messageUsage(u));
|
|
480
|
+
};
|
|
481
|
+
// The message each stream (the Boton's, a subagent's) is writing, for its message_delta's usage.
|
|
482
|
+
const writing = new Map<string, string>();
|
|
483
|
+
let streamed: string | null = null;
|
|
484
|
+
let draft = '';
|
|
485
|
+
const tools = new Set<string>();
|
|
486
|
+
// The turn's MCP calls by tool_use id, until their results come.
|
|
487
|
+
const calls = new Map<string, { server: string; tool: string }>();
|
|
488
|
+
// The turn's own init event (a subagent's has a parent tool call), and the servers a tool call ran on: a request
|
|
489
|
+
// alone proves nothing (Claude Code connects a server with cached tools on its first call, which can fail).
|
|
490
|
+
let init: any = null;
|
|
491
|
+
const called = new Set<string>();
|
|
492
|
+
|
|
493
|
+
const servers = req.mcpServers ?? [];
|
|
494
|
+
const serverNames = servers.map((s) => s.name);
|
|
495
|
+
let mcpFile: string | undefined;
|
|
496
|
+
if (servers.length) {
|
|
497
|
+
if (!req.privateDir) throw new Error('privateDir is required for a turn with MCP servers');
|
|
498
|
+
// Secrets: a new file only the server's user can read, in a folder Workspace Botons can't, never on argv.
|
|
499
|
+
mcpFile = join(req.privateDir, `mcp-${randomBytes(9).toString('hex')}.json`);
|
|
500
|
+
writeFileSync(mcpFile, claudeMcpConfig(servers, req.askFirst), { mode: 0o600, flag: 'wx' });
|
|
501
|
+
}
|
|
502
|
+
const snapshot = cliTurn(req, 'claude', { DISABLE_AUTOUPDATER: '1', DISABLE_UPDATES: '1' });
|
|
503
|
+
const run = await runJsonl(this.#bin, claudeArgs(snapshot.req, mcpFile), {
|
|
504
|
+
cwd: req.cwd,
|
|
505
|
+
groupDir: req.groupDir,
|
|
506
|
+
env: snapshot.env,
|
|
507
|
+
stdin: req.prompt,
|
|
508
|
+
signal: req.signal,
|
|
509
|
+
timeoutMs: req.timeoutMs,
|
|
510
|
+
onEvent: (ev) => {
|
|
511
|
+
if (typeof ev.session_id === 'string') sessionId = ev.session_id;
|
|
512
|
+
if (ev.type === 'system' && ev.subtype === 'init') {
|
|
513
|
+
if (ev.parent_tool_use_id == null) init ??= ev;
|
|
514
|
+
if (typeof ev.model === 'string' && ev.model) model = ev.model;
|
|
515
|
+
if (!req.sessionId) logNewSession(ev);
|
|
516
|
+
}
|
|
517
|
+
if (ev.type === 'assistant' || ev.type === 'user') activity = true;
|
|
518
|
+
if (ev.type === 'assistant' && ev.message?.usage && typeof ev.message.id === 'string') {
|
|
519
|
+
saw(ev.message.id, ev.message.usage);
|
|
520
|
+
if (!model && typeof ev.message.model === 'string') model = ev.message.model;
|
|
521
|
+
}
|
|
522
|
+
if (ev.type === 'stream_event') {
|
|
523
|
+
const e = ev.event;
|
|
524
|
+
const stream = String(ev.parent_tool_use_id ?? '');
|
|
525
|
+
if (e?.type === 'message_start' && typeof e.message?.id === 'string') {
|
|
526
|
+
writing.set(stream, e.message.id);
|
|
527
|
+
if (e.message.usage) saw(e.message.id, e.message.usage);
|
|
528
|
+
} else if (e?.type === 'message_delta' && e.usage && writing.has(stream)) saw(writing.get(stream)!, e.usage);
|
|
529
|
+
}
|
|
530
|
+
// Subagents' messages have a parent tool call; only the Boton's own text is its reply.
|
|
531
|
+
const top = ev.parent_tool_use_id == null;
|
|
532
|
+
if (ev.type === 'stream_event' && top) {
|
|
533
|
+
const e = ev.event;
|
|
534
|
+
if (e?.type === 'message_start') {
|
|
535
|
+
streamed = typeof e.message?.id === 'string' ? e.message.id : null;
|
|
536
|
+
draft = '';
|
|
537
|
+
} else if (e?.type === 'content_block_start' && e.content_block?.type === 'text') req.onActivity('Writing');
|
|
538
|
+
else if (e?.type === 'content_block_start' && e.content_block?.type === 'thinking') req.onActivity('Thinking');
|
|
539
|
+
else if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && typeof e.delta.text === 'string') {
|
|
540
|
+
draft += e.delta.text;
|
|
541
|
+
req.onText?.(draft);
|
|
542
|
+
}
|
|
543
|
+
} else if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
|
|
544
|
+
for (const block of ev.message.content) {
|
|
545
|
+
if (block.type === 'tool_use') {
|
|
546
|
+
const mcp = typeof block.name === 'string' && typeof block.id === 'string' ? claudeMcpTool(block.name, serverNames) : null;
|
|
547
|
+
if (mcp) calls.set(block.id, mcp);
|
|
548
|
+
req.onActivity(describeTool(block.name, block.input));
|
|
549
|
+
if (typeof block.id === 'string' && tools.has(block.id)) continue;
|
|
550
|
+
tools.add(block.id);
|
|
551
|
+
const s = toolStep(String(block.name ?? ''), block.input);
|
|
552
|
+
if (s) req.onStep?.(s);
|
|
553
|
+
} else if (block.type === 'thinking') req.onActivity('Thinking');
|
|
554
|
+
else if (block.type === 'text' && block.text?.trim() && top) {
|
|
555
|
+
lastText = block.text;
|
|
556
|
+
req.onActivity('Writing');
|
|
557
|
+
// Already streamed as deltas, or a message that came whole.
|
|
558
|
+
if (ev.message.id !== streamed) {
|
|
559
|
+
draft = block.text;
|
|
560
|
+
req.onText?.(draft);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
|
|
565
|
+
for (const block of ev.message.content) {
|
|
566
|
+
const call = block?.type === 'tool_result' ? calls.get(String(block.tool_use_id)) : undefined;
|
|
567
|
+
if (!call) continue;
|
|
568
|
+
calls.delete(String(block.tool_use_id));
|
|
569
|
+
const obs = claudeToolResult(block, call, ev.tool_use_result);
|
|
570
|
+
// Claude Code's stream doesn't mark an error result as the server's own or its failure to reach the
|
|
571
|
+
// server, so only a result that isn't an error shows the server ran the call.
|
|
572
|
+
if (obs.ok) called.add(call.server);
|
|
573
|
+
if (!req.onToolResult) continue;
|
|
574
|
+
try {
|
|
575
|
+
req.onToolResult(obs);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
log.warn('could not record a tool result', { provider: 'claude-cli', err });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} else if (ev.type === 'result') final = ev;
|
|
581
|
+
},
|
|
582
|
+
isDone: (ev) => ev?.type === 'result' && ev.subtype === 'success' && !ev.is_error,
|
|
583
|
+
}).finally(() => {
|
|
584
|
+
if (mcpFile) rmSync(mcpFile, { force: true });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Nothing to tell when the CLI never got as far as its init event.
|
|
588
|
+
const mcpStartup = servers.length && init ? claudeMcpStartup(init, serverNames, called) : undefined;
|
|
589
|
+
const seen = [...responses.values()].reduce((sum, u) => combine(sum, u, (x, y) => x + y), noTokens());
|
|
590
|
+
const usage = final
|
|
591
|
+
? claudeUsage(final, model, { resumed: !!req.sessionId, seen: withModel(seen, null), before: sessionId ? this.#totals.get(sessionId) : null })
|
|
592
|
+
: withModel(seen, model);
|
|
593
|
+
if (final && sessionId) {
|
|
594
|
+
this.#totals.delete(sessionId);
|
|
595
|
+
if (this.#totals.size >= 500) this.#totals.delete(this.#totals.keys().next().value!);
|
|
596
|
+
this.#totals.set(sessionId, sessionTotals(final));
|
|
597
|
+
}
|
|
598
|
+
const text = typeof final?.result === 'string' ? final.result : '';
|
|
599
|
+
const isError = !!final?.is_error;
|
|
600
|
+
const subtype = final?.subtype ?? '';
|
|
601
|
+
// A result it wrote once stopped or past its time limit doesn't count: that ended the turn first.
|
|
602
|
+
if (final && !isError && subtype === 'success' && !run.aborted && !run.timedOut) return { sessionId, text: text || lastText, usage, ...(mcpStartup ? { mcpStartup } : {}) };
|
|
603
|
+
const errors = final && Array.isArray(final.errors) ? final.errors.filter((e: unknown) => typeof e === 'string').join('\n') : '';
|
|
604
|
+
const lost = !!req.sessionId && !activity && !run.aborted && !run.timedOut && `${errors}\n${run.stderr}`.includes(MISSING_SESSION);
|
|
605
|
+
const detail = isError ? text || errors || `Claude returned ${subtype}` : null;
|
|
606
|
+
const message = failureMessage('Claude', run, detail);
|
|
607
|
+
// Claude's stderr is quiet on a normal run, so with no result event it is the failure itself.
|
|
608
|
+
const info = run.aborted || run.timedOut || lost ? {} : classifyFailure(`${message}\n${errors}\n${run.stderr}`, final ? `${detail ?? ''}\n${errors}` : `${message}\n${run.stderr}`);
|
|
609
|
+
const err = withExit(new ProviderError(message, lost, info), run);
|
|
610
|
+
err.usage = usage;
|
|
611
|
+
if (mcpStartup) err.mcpStartup = mcpStartup;
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The usage, or null when it has no tokens and no model. */
|
|
617
|
+
function withModel(usage: TurnUsage, model: string | null): TurnUsage | null {
|
|
618
|
+
if (model) usage.model = model;
|
|
619
|
+
return anyTokens(usage) || model ? usage : null;
|
|
620
|
+
}
|