@peakflo/agent-harness 0.0.0-stage → 0.2.1-rc.3
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 +38 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,1918 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code Adapter
|
|
4
|
+
*
|
|
5
|
+
* Implements CodingAgentAdapter for Claude Code using @anthropic-ai/claude-agent-sdk.
|
|
6
|
+
*
|
|
7
|
+
* Key differences from OpenCode:
|
|
8
|
+
* - Uses AsyncGenerator streaming API instead of HTTP client
|
|
9
|
+
* - File-based session persistence (~/.claude/sessions/)
|
|
10
|
+
* - Different message format (SDKMessage vs OpenCode messages)
|
|
11
|
+
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.ClaudeCodeAdapter = exports.ClaudeSystemSubtype = void 0;
|
|
47
|
+
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
48
|
+
const permission_mode_1 = require("../runtime/permission-mode");
|
|
49
|
+
let ClaudeAgentSDK = null;
|
|
50
|
+
/** Module-level cache for the resolved Claude executable path (persists across adapter instances) */
|
|
51
|
+
let resolvedClaudeExecutablePath = null;
|
|
52
|
+
/** Maximum number of messages to keep in the buffer per session */
|
|
53
|
+
const MAX_MESSAGE_BUFFER_SIZE = 500;
|
|
54
|
+
var ClaudeSystemSubtype;
|
|
55
|
+
(function (ClaudeSystemSubtype) {
|
|
56
|
+
ClaudeSystemSubtype["INIT"] = "init";
|
|
57
|
+
ClaudeSystemSubtype["TASK_STARTED"] = "task_started";
|
|
58
|
+
ClaudeSystemSubtype["TASK_PROGRESS"] = "task_progress";
|
|
59
|
+
ClaudeSystemSubtype["TASK_NOTIFICATION"] = "task_notification";
|
|
60
|
+
ClaudeSystemSubtype["TASK_UPDATED"] = "task_updated";
|
|
61
|
+
/** Authoritative in-flight background-task list. Emitted by the CLI but NOT
|
|
62
|
+
* surfaced by SDK >= 0.3.x (absent from the SDKMessage union) — older bundled
|
|
63
|
+
* SDKs (e.g. 0.2.x) do pass it through, so it is handled defensively both as a
|
|
64
|
+
* status source and as a transcript-suppression case. */
|
|
65
|
+
ClaudeSystemSubtype["BACKGROUND_TASKS_CHANGED"] = "background_tasks_changed";
|
|
66
|
+
ClaudeSystemSubtype["STATUS"] = "status";
|
|
67
|
+
ClaudeSystemSubtype["THINKING_TOKENS"] = "thinking_tokens";
|
|
68
|
+
})(ClaudeSystemSubtype || (exports.ClaudeSystemSubtype = ClaudeSystemSubtype = {}));
|
|
69
|
+
/**
|
|
70
|
+
* Terminal states for a Claude Code background task. Once a task reports one of
|
|
71
|
+
* these (via `task_updated.patch.status` or `task_notification.status`) it is no
|
|
72
|
+
* longer in flight and stops counting towards the session being BUSY.
|
|
73
|
+
*/
|
|
74
|
+
const TERMINAL_TASK_STATUSES = new Set(['completed', 'failed', 'killed', 'stopped']);
|
|
75
|
+
/**
|
|
76
|
+
* Safety cap on how long a background task may be considered "in flight".
|
|
77
|
+
* Because in-flight background work suppresses IDLE *and* exempts the session
|
|
78
|
+
* from agent-manager's stuck-session watchdog, a task whose terminal
|
|
79
|
+
* notification is lost would otherwise pin the session BUSY forever. Generous
|
|
80
|
+
* enough that no legitimate subagent hits it.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_BACKGROUND_TASK_AGE_MS = 60 * 60 * 1000; // 60 minutes
|
|
83
|
+
class ClaudeCodeAdapter {
|
|
84
|
+
sessions = new Map();
|
|
85
|
+
sdkLoading = null;
|
|
86
|
+
claudeExecutablePath = null;
|
|
87
|
+
/**
|
|
88
|
+
* Callback set by agent-manager to trigger an immediate poll cycle
|
|
89
|
+
* when new stream data is buffered. Eliminates the up-to-2-second
|
|
90
|
+
* latency of the fixed-interval polling heartbeat.
|
|
91
|
+
*/
|
|
92
|
+
onDataAvailable;
|
|
93
|
+
constructor() {
|
|
94
|
+
this.sdkLoading = this.loadSDK();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Find the Claude CLI executable path
|
|
98
|
+
*/
|
|
99
|
+
async findClaudeExecutable() {
|
|
100
|
+
if (this.claudeExecutablePath) {
|
|
101
|
+
return this.claudeExecutablePath;
|
|
102
|
+
}
|
|
103
|
+
// Check module-level cache (persists across adapter instances)
|
|
104
|
+
if (resolvedClaudeExecutablePath) {
|
|
105
|
+
this.claudeExecutablePath = resolvedClaudeExecutablePath;
|
|
106
|
+
return this.claudeExecutablePath;
|
|
107
|
+
}
|
|
108
|
+
const isWin = process.platform === 'win32';
|
|
109
|
+
try {
|
|
110
|
+
const { execFile } = await Promise.resolve().then(() => __importStar(require('child_process')));
|
|
111
|
+
const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
|
|
112
|
+
const execFileAsync = promisify(execFile);
|
|
113
|
+
// Try to find claude in PATH
|
|
114
|
+
const whichCmd = isWin ? 'where' : 'which';
|
|
115
|
+
const binaryName = 'claude';
|
|
116
|
+
const { stdout } = await execFileAsync(whichCmd, [binaryName]);
|
|
117
|
+
let found = stdout.trim().split(/\r?\n/)[0];
|
|
118
|
+
// On Windows, the SDK spawns the executable directly without shell,
|
|
119
|
+
// so .cmd files fail with EINVAL. Resolve .cmd → the underlying cli.js
|
|
120
|
+
// so the SDK uses `node cli.js` instead.
|
|
121
|
+
if (isWin) {
|
|
122
|
+
found = await this.resolveWindowsCmdToJs(found);
|
|
123
|
+
}
|
|
124
|
+
this.claudeExecutablePath = found;
|
|
125
|
+
resolvedClaudeExecutablePath = found;
|
|
126
|
+
console.log(`[ClaudeCodeAdapter] Found claude executable at: ${this.claudeExecutablePath}`);
|
|
127
|
+
return this.claudeExecutablePath;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Common installation locations — must cover Homebrew, npm globals,
|
|
131
|
+
// pnpm globals, Volta, and NVM paths so the binary is found even when
|
|
132
|
+
// the shell PATH wasn't fully resolved (packaged macOS GUI app).
|
|
133
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
134
|
+
const commonPaths = isWin
|
|
135
|
+
? [
|
|
136
|
+
`${home}\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js`,
|
|
137
|
+
`${home}\\AppData\\Roaming\\npm\\claude.cmd`,
|
|
138
|
+
`${home}\\AppData\\Roaming\\npm\\claude.exe`,
|
|
139
|
+
`${home}\\.local\\bin\\claude.cmd`,
|
|
140
|
+
`${home}\\.local\\bin\\claude.exe`
|
|
141
|
+
]
|
|
142
|
+
: [
|
|
143
|
+
'/usr/local/bin/claude',
|
|
144
|
+
'/opt/homebrew/bin/claude',
|
|
145
|
+
`${home}/.local/bin/claude`,
|
|
146
|
+
`${home}/.npm-global/bin/claude`,
|
|
147
|
+
`${home}/Library/pnpm/claude`,
|
|
148
|
+
`${home}/.volta/bin/claude`,
|
|
149
|
+
];
|
|
150
|
+
// Dynamically detect NVM-managed npm global bin (avoids hardcoding a Node version)
|
|
151
|
+
if (!isWin && home) {
|
|
152
|
+
try {
|
|
153
|
+
const { join } = await Promise.resolve().then(() => __importStar(require('path')));
|
|
154
|
+
const { readdirSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
155
|
+
const nvmDir = join(home, '.nvm', 'versions', 'node');
|
|
156
|
+
const versions = readdirSync(nvmDir);
|
|
157
|
+
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
158
|
+
for (const v of versions) {
|
|
159
|
+
commonPaths.push(join(nvmDir, v, 'bin', 'claude'));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// NVM not installed — skip
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
167
|
+
for (const path of commonPaths) {
|
|
168
|
+
if (existsSync(path)) {
|
|
169
|
+
let resolved = path;
|
|
170
|
+
if (isWin && path.endsWith('.cmd')) {
|
|
171
|
+
resolved = await this.resolveWindowsCmdToJs(path);
|
|
172
|
+
}
|
|
173
|
+
this.claudeExecutablePath = resolved;
|
|
174
|
+
resolvedClaudeExecutablePath = resolved;
|
|
175
|
+
console.log(`[ClaudeCodeAdapter] Found claude executable at: ${this.claudeExecutablePath}`);
|
|
176
|
+
return this.claudeExecutablePath;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw new Error('Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* On Windows, .cmd wrapper files can't be spawned directly by the SDK
|
|
184
|
+
* (no shell: true). Resolve claude.cmd → the underlying cli.js path.
|
|
185
|
+
*/
|
|
186
|
+
async resolveWindowsCmdToJs(cmdPath) {
|
|
187
|
+
try {
|
|
188
|
+
const { readFileSync, existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
189
|
+
const { join, dirname } = await Promise.resolve().then(() => __importStar(require('path')));
|
|
190
|
+
// Try the known npm global layout first: same dir as .cmd → node_modules/@anthropic-ai/claude-code/cli.js
|
|
191
|
+
const cmdDir = dirname(cmdPath);
|
|
192
|
+
const cliJs = join(cmdDir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
|
|
193
|
+
if (existsSync(cliJs)) {
|
|
194
|
+
console.log(`[ClaudeCodeAdapter] Resolved .cmd → ${cliJs}`);
|
|
195
|
+
return cliJs;
|
|
196
|
+
}
|
|
197
|
+
// Parse the .cmd file to extract the JS path
|
|
198
|
+
const content = readFileSync(cmdPath, 'utf8');
|
|
199
|
+
const match = content.match(/"[^"]*node(?:\.exe)?"[^"]*"([^"]+\.js)"/)
|
|
200
|
+
|| content.match(/node(?:\.exe)?\s+"([^"]+\.js)"/)
|
|
201
|
+
|| content.match(/node(?:\.exe)?\s+([^\s]+\.js)/);
|
|
202
|
+
if (match?.[1]) {
|
|
203
|
+
const resolvedJs = match[1].includes('%dp0%')
|
|
204
|
+
? match[1].replace(/%dp0%/g, cmdDir + '\\')
|
|
205
|
+
: match[1];
|
|
206
|
+
if (existsSync(resolvedJs)) {
|
|
207
|
+
console.log(`[ClaudeCodeAdapter] Parsed .cmd → ${resolvedJs}`);
|
|
208
|
+
return resolvedJs;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.warn(`[ClaudeCodeAdapter] Failed to resolve .cmd to .js:`, err);
|
|
214
|
+
}
|
|
215
|
+
// Fall back to original .cmd path
|
|
216
|
+
return cmdPath;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build environment variables for Claude process
|
|
220
|
+
* Removes CLAUDECODE to prevent nested session errors.
|
|
221
|
+
* When secrets are configured, sets SHELL to the secret-shell.sh wrapper
|
|
222
|
+
* so every bash command fetches secrets from the broker transparently.
|
|
223
|
+
*/
|
|
224
|
+
buildClaudeEnvironment() {
|
|
225
|
+
const env = { ...process.env };
|
|
226
|
+
// Remove CLAUDECODE to prevent nested session error
|
|
227
|
+
delete env.CLAUDECODE;
|
|
228
|
+
return env;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Build PreToolUse hooks for secret injection.
|
|
232
|
+
* Registers a hook that prepends `export KEY='value'` lines to each Bash command.
|
|
233
|
+
* The LLM never sees the modified command — only the original tool call and
|
|
234
|
+
* the output appear in conversation context.
|
|
235
|
+
*/
|
|
236
|
+
buildSecretHooks(config) {
|
|
237
|
+
const secretEnvVars = config.secretEnvVars;
|
|
238
|
+
if (!secretEnvVars || Object.keys(secretEnvVars).length === 0) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
// Build shell export lines — single-quote values, escape embedded quotes
|
|
242
|
+
const exportLines = Object.entries(secretEnvVars)
|
|
243
|
+
.map(([k, v]) => `export ${k}='${v.replace(/'/g, "'\\''")}'`)
|
|
244
|
+
.join('\n');
|
|
245
|
+
console.log(`[ClaudeCodeAdapter] Registering PreToolUse hook for secrets: [${Object.keys(secretEnvVars).join(', ')}]`);
|
|
246
|
+
const hook = async (input) => {
|
|
247
|
+
const toolInput = ('tool_input' in input ? input.tool_input : undefined);
|
|
248
|
+
if (!toolInput?.command) {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
// Prepend secret exports to the bash command
|
|
252
|
+
const originalCommand = toolInput.command;
|
|
253
|
+
const modifiedCommand = exportLines + '\n' + originalCommand;
|
|
254
|
+
return {
|
|
255
|
+
hookSpecificOutput: {
|
|
256
|
+
hookEventName: 'PreToolUse',
|
|
257
|
+
updatedInput: {
|
|
258
|
+
...toolInput,
|
|
259
|
+
command: modifiedCommand
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
PreToolUse: [{
|
|
266
|
+
matcher: 'Bash',
|
|
267
|
+
hooks: [hook]
|
|
268
|
+
}]
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async loadSDK() {
|
|
272
|
+
try {
|
|
273
|
+
ClaudeAgentSDK = await Promise.resolve().then(() => __importStar(require('@anthropic-ai/claude-agent-sdk')));
|
|
274
|
+
console.log('[ClaudeCodeAdapter] SDK loaded successfully');
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
console.error('[ClaudeCodeAdapter] Failed to load SDK:', error);
|
|
278
|
+
ClaudeAgentSDK = null;
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
this.sdkLoading = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async ensureSDKLoaded() {
|
|
285
|
+
if (ClaudeAgentSDK)
|
|
286
|
+
return;
|
|
287
|
+
if (this.sdkLoading) {
|
|
288
|
+
await this.sdkLoading;
|
|
289
|
+
}
|
|
290
|
+
if (!ClaudeAgentSDK) {
|
|
291
|
+
throw new Error('Claude Agent SDK failed to load');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async initialize() {
|
|
295
|
+
await this.ensureSDKLoaded();
|
|
296
|
+
}
|
|
297
|
+
async createSession(config) {
|
|
298
|
+
await this.ensureSDKLoaded();
|
|
299
|
+
if (!ClaudeAgentSDK) {
|
|
300
|
+
throw new Error('Claude Agent SDK not loaded');
|
|
301
|
+
}
|
|
302
|
+
// Create session state (without starting a query yet)
|
|
303
|
+
// The first sendPrompt call will start the actual query
|
|
304
|
+
const session = {
|
|
305
|
+
sessionId: '', // Will be set from first message
|
|
306
|
+
queryIterator: null,
|
|
307
|
+
abortController: null,
|
|
308
|
+
status: 'idle',
|
|
309
|
+
messageBuffer: [],
|
|
310
|
+
messageCursor: 0,
|
|
311
|
+
streamTask: null,
|
|
312
|
+
lastError: null,
|
|
313
|
+
config, // Store config for use in sendPrompt
|
|
314
|
+
isResumed: false, // New session, not resumed
|
|
315
|
+
backgroundTasks: new Map(),
|
|
316
|
+
sawResult: false,
|
|
317
|
+
enqueuePrompt: null,
|
|
318
|
+
releasePrompt: null,
|
|
319
|
+
};
|
|
320
|
+
// Generate UUID-format session ID (required by Claude Code)
|
|
321
|
+
const { randomUUID } = await Promise.resolve().then(() => __importStar(require('crypto')));
|
|
322
|
+
const sessionId = randomUUID();
|
|
323
|
+
this.sessions.set(sessionId, session);
|
|
324
|
+
console.log(`[ClaudeCodeAdapter] Session created: ${sessionId}`);
|
|
325
|
+
return sessionId;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Check if a session ID is valid for Claude Code (UUID format)
|
|
329
|
+
*/
|
|
330
|
+
isValidClaudeSessionId(sessionId) {
|
|
331
|
+
// Claude Code expects UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
332
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
333
|
+
return uuidRegex.test(sessionId);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Load conversation history from Claude session file
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* Cleans the session file by removing messages with empty text blocks
|
|
340
|
+
* This prevents API errors when resuming sessions
|
|
341
|
+
*/
|
|
342
|
+
async cleanSessionFile(sessionId, workspaceDir) {
|
|
343
|
+
try {
|
|
344
|
+
const { readFileSync, writeFileSync, existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
345
|
+
const { join } = await Promise.resolve().then(() => __importStar(require('path')));
|
|
346
|
+
const { homedir } = await Promise.resolve().then(() => __importStar(require('os')));
|
|
347
|
+
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
348
|
+
// Claude Code CLI encodes workspace paths by replacing all non-alphanumeric/non-hyphen chars with '-'
|
|
349
|
+
const encodedWorkspace = workspaceDir.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
350
|
+
const sessionFile = join(claudeDir, encodedWorkspace, `${sessionId}.jsonl`);
|
|
351
|
+
console.log(`[ClaudeCodeAdapter] Cleaning session file: ${sessionFile}`);
|
|
352
|
+
// Check if session file exists
|
|
353
|
+
if (!existsSync(sessionFile)) {
|
|
354
|
+
console.log(`[ClaudeCodeAdapter] Session file not found, skipping clean: ${sessionFile}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const content = readFileSync(sessionFile, 'utf-8');
|
|
358
|
+
const lines = content.trim().split('\n');
|
|
359
|
+
const cleanedLines = [];
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
const entry = JSON.parse(line);
|
|
362
|
+
// Keep non-message entries as-is
|
|
363
|
+
if (entry.type !== 'user' && entry.type !== 'assistant') {
|
|
364
|
+
cleanedLines.push(line);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// Check if message has content with empty text blocks
|
|
368
|
+
if (entry.message?.content) {
|
|
369
|
+
let hasEmptyText = false;
|
|
370
|
+
for (const contentPart of entry.message.content) {
|
|
371
|
+
if (contentPart.type === 'text' && (!contentPart.text || contentPart.text.trim() === '')) {
|
|
372
|
+
hasEmptyText = true;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Skip messages with empty text blocks
|
|
377
|
+
if (hasEmptyText) {
|
|
378
|
+
console.log(`[ClaudeCodeAdapter] Removing message with empty text block: ${entry.uuid}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
cleanedLines.push(line);
|
|
383
|
+
}
|
|
384
|
+
// Write cleaned content back
|
|
385
|
+
writeFileSync(sessionFile, cleanedLines.join('\n') + '\n', 'utf-8');
|
|
386
|
+
console.log(`[ClaudeCodeAdapter] Session file cleaned: ${lines.length} -> ${cleanedLines.length} lines`);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
console.warn(`[ClaudeCodeAdapter] Failed to clean session file:`, error);
|
|
390
|
+
// Don't throw - let resume attempt proceed even if cleaning fails
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Public, side-effect-free read of the persisted session history from the
|
|
395
|
+
* on-disk JSONL — no CLI/SDK spawn, no live session required. Used to backfill
|
|
396
|
+
* the durable transcript projection once. Returns [] if the file is missing.
|
|
397
|
+
*/
|
|
398
|
+
async getPersistedMessages(sessionId, config) {
|
|
399
|
+
const workspaceDir = config.workspaceDir;
|
|
400
|
+
if (!workspaceDir)
|
|
401
|
+
return [];
|
|
402
|
+
try {
|
|
403
|
+
return await this.loadSessionHistory(sessionId, workspaceDir);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async loadSessionHistory(sessionId, workspaceDir) {
|
|
410
|
+
try {
|
|
411
|
+
const { readFileSync, existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
412
|
+
const { join } = await Promise.resolve().then(() => __importStar(require('path')));
|
|
413
|
+
const { homedir } = await Promise.resolve().then(() => __importStar(require('os')));
|
|
414
|
+
// Session files are stored in: ~/.claude/projects/[encoded-workspace]/[sessionId].jsonl
|
|
415
|
+
// Claude Code CLI encodes workspace paths by replacing all non-alphanumeric/non-hyphen chars with '-'
|
|
416
|
+
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
417
|
+
const encodedWorkspace = workspaceDir.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
418
|
+
const sessionFile = join(claudeDir, encodedWorkspace, `${sessionId}.jsonl`);
|
|
419
|
+
console.log(`[ClaudeCodeAdapter] Loading session history from: ${sessionFile}`);
|
|
420
|
+
// Check if session file exists
|
|
421
|
+
if (!existsSync(sessionFile)) {
|
|
422
|
+
console.warn(`[ClaudeCodeAdapter] Session file not found: ${sessionFile}`);
|
|
423
|
+
throw new Error('SESSION_FILE_NOT_FOUND: The Claude Code session file does not exist. This may happen if the session was deleted or never synced.');
|
|
424
|
+
}
|
|
425
|
+
const content = readFileSync(sessionFile, 'utf-8');
|
|
426
|
+
const lines = content.trim().split('\n');
|
|
427
|
+
const messages = [];
|
|
428
|
+
// Map tool_use_id → part reference so tool_result can merge into it
|
|
429
|
+
const toolUseParts = new Map();
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
const entry = JSON.parse(line);
|
|
432
|
+
// Skip non-message entries (queue-operation, etc.)
|
|
433
|
+
if (entry.type !== 'user' && entry.type !== 'assistant')
|
|
434
|
+
continue;
|
|
435
|
+
const message = {
|
|
436
|
+
id: entry.uuid,
|
|
437
|
+
role: entry.type === 'user' ? coding_agent_adapter_1.MessageRole.USER : coding_agent_adapter_1.MessageRole.ASSISTANT,
|
|
438
|
+
parts: []
|
|
439
|
+
};
|
|
440
|
+
// Parse message content
|
|
441
|
+
// Use the stable API message ID for part IDs — must match the pattern
|
|
442
|
+
// in convertSDKMessageToParts so that dedup state from loadSessionHistory
|
|
443
|
+
// correctly prevents re-emission of historical messages during streaming replay.
|
|
444
|
+
const stableId = entry.message?.id || entry.uuid || entry.type;
|
|
445
|
+
if (entry.message?.content) {
|
|
446
|
+
for (let blockIdx = 0; blockIdx < entry.message.content.length; blockIdx++) {
|
|
447
|
+
const contentPart = entry.message.content[blockIdx];
|
|
448
|
+
if (contentPart.type === 'text') {
|
|
449
|
+
// Skip empty text blocks (corrupt messages)
|
|
450
|
+
if (!contentPart.text || contentPart.text.trim() === '')
|
|
451
|
+
continue;
|
|
452
|
+
message.parts.push({
|
|
453
|
+
id: `${stableId}-text-${blockIdx}`,
|
|
454
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
455
|
+
text: contentPart.text,
|
|
456
|
+
content: contentPart.text
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
else if (contentPart.type === 'thinking') {
|
|
460
|
+
if (!contentPart.thinking || contentPart.thinking.trim() === '')
|
|
461
|
+
continue;
|
|
462
|
+
message.parts.push({
|
|
463
|
+
id: `${stableId}-thinking-${blockIdx}`,
|
|
464
|
+
type: coding_agent_adapter_1.MessagePartType.REASONING,
|
|
465
|
+
text: contentPart.thinking,
|
|
466
|
+
content: contentPart.thinking
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
else if (contentPart.type === 'tool_use') {
|
|
470
|
+
const rawInput = contentPart.input;
|
|
471
|
+
const toolName = contentPart.name || 'unknown';
|
|
472
|
+
const title = this.buildToolTitle(toolName, rawInput);
|
|
473
|
+
const input = rawInput ? JSON.stringify(rawInput, null, 2) : undefined;
|
|
474
|
+
const toolObj = {
|
|
475
|
+
name: toolName,
|
|
476
|
+
status: 'pending',
|
|
477
|
+
title,
|
|
478
|
+
input,
|
|
479
|
+
};
|
|
480
|
+
// Detect TodoWrite → set todowrite type and extract todos
|
|
481
|
+
let partType = coding_agent_adapter_1.MessagePartType.TOOL;
|
|
482
|
+
let todos = rawInput?.todos;
|
|
483
|
+
if (typeof todos === 'string') {
|
|
484
|
+
try {
|
|
485
|
+
todos = JSON.parse(todos);
|
|
486
|
+
}
|
|
487
|
+
catch { }
|
|
488
|
+
}
|
|
489
|
+
if (Array.isArray(todos) && todos.length > 0) {
|
|
490
|
+
partType = 'todowrite';
|
|
491
|
+
toolObj.todos = todos.map((t, i) => ({
|
|
492
|
+
id: t.id || `todo-${i}`,
|
|
493
|
+
content: t.content || '',
|
|
494
|
+
status: t.status || 'pending',
|
|
495
|
+
priority: t.priority,
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
// Detect AskUserQuestion → set question type and extract questions
|
|
499
|
+
let questions = rawInput?.questions;
|
|
500
|
+
if (typeof questions === 'string') {
|
|
501
|
+
try {
|
|
502
|
+
questions = JSON.parse(questions);
|
|
503
|
+
}
|
|
504
|
+
catch { }
|
|
505
|
+
}
|
|
506
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
507
|
+
partType = 'question';
|
|
508
|
+
toolObj.questions = questions;
|
|
509
|
+
}
|
|
510
|
+
// Detect EnterPlanMode / ExitPlanMode → set planreview type
|
|
511
|
+
// For ExitPlanMode, the plan content is in input.plan (not in tool_result)
|
|
512
|
+
if (toolName === 'EnterPlanMode' || toolName === 'ExitPlanMode') {
|
|
513
|
+
partType = 'planreview';
|
|
514
|
+
toolObj.title = toolName === 'EnterPlanMode' ? 'Enter plan mode' : 'Exit plan mode';
|
|
515
|
+
if (toolName === 'ExitPlanMode' && rawInput?.plan) {
|
|
516
|
+
toolObj.output = String(rawInput.plan).slice(0, 50000);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const toolPartId = contentPart.id ? `tool-${contentPart.id}` : `${stableId}-tool_use-${contentPart.id || blockIdx}`;
|
|
520
|
+
message.parts.push({ id: toolPartId, type: partType, tool: toolObj });
|
|
521
|
+
if (contentPart.id)
|
|
522
|
+
toolUseParts.set(contentPart.id, toolObj);
|
|
523
|
+
}
|
|
524
|
+
else if (contentPart.type === 'tool_result' && contentPart.tool_use_id) {
|
|
525
|
+
// Merge result into the matching tool_use part instead of creating separate entry
|
|
526
|
+
const matchingTool = toolUseParts.get(contentPart.tool_use_id);
|
|
527
|
+
if (matchingTool) {
|
|
528
|
+
matchingTool.status = 'success';
|
|
529
|
+
// Plan content should not be truncated (cap at 50K for safety)
|
|
530
|
+
const isPlan = matchingTool.name === 'ExitPlanMode' || matchingTool.name === 'EnterPlanMode';
|
|
531
|
+
const rawContent = contentPart.content ? String(contentPart.content) : undefined;
|
|
532
|
+
// Filter out confirmation prompts like "Exit/Enter plan mode?"
|
|
533
|
+
const sanitized = isPlan && rawContent && /^(exit|enter) plan mode\??$/i.test(rawContent.trim())
|
|
534
|
+
? undefined : rawContent;
|
|
535
|
+
matchingTool.output = sanitized
|
|
536
|
+
? (isPlan ? sanitized.slice(0, 50000) : sanitized.slice(0, 2000))
|
|
537
|
+
: undefined;
|
|
538
|
+
}
|
|
539
|
+
// Don't push a separate part — result is merged into tool_use
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (message.parts.length > 0) {
|
|
544
|
+
// Preserve the ORIGINAL event time from the session log so replay/
|
|
545
|
+
// rehydration shows real timestamps (and the durable projection stores
|
|
546
|
+
// them as created_at) instead of defaulting to "now" on every reload.
|
|
547
|
+
const receivedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
548
|
+
if (!Number.isNaN(receivedAt)) {
|
|
549
|
+
for (const part of message.parts)
|
|
550
|
+
part.receivedAt = receivedAt;
|
|
551
|
+
}
|
|
552
|
+
messages.push(message);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
console.log(`[ClaudeCodeAdapter] Loaded ${messages.length} messages from session history`);
|
|
556
|
+
return messages;
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
// Re-throw session file not found errors
|
|
560
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
561
|
+
if (errMsg.includes('SESSION_FILE_NOT_FOUND')) {
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
// For other errors, warn and return empty array
|
|
565
|
+
console.warn(`[ClaudeCodeAdapter] Failed to load session history:`, errMsg);
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async resumeSession(sessionId, config) {
|
|
570
|
+
await this.ensureSDKLoaded();
|
|
571
|
+
if (!ClaudeAgentSDK) {
|
|
572
|
+
throw new Error('Claude Agent SDK not loaded');
|
|
573
|
+
}
|
|
574
|
+
// Validate session ID format
|
|
575
|
+
if (!this.isValidClaudeSessionId(sessionId)) {
|
|
576
|
+
console.warn(`[ClaudeCodeAdapter] Invalid session ID format: ${sessionId}`);
|
|
577
|
+
throw new Error('INCOMPATIBLE_SESSION_ID: This session was created with a different coding agent and cannot be resumed with Claude Code.');
|
|
578
|
+
}
|
|
579
|
+
// Clean session file to remove empty text blocks before resuming
|
|
580
|
+
await this.cleanSessionFile(sessionId, config.workspaceDir);
|
|
581
|
+
// Create session state (idle until user sends a message)
|
|
582
|
+
const session = {
|
|
583
|
+
sessionId,
|
|
584
|
+
queryIterator: null, // Will be created when user sends first message
|
|
585
|
+
abortController: null,
|
|
586
|
+
status: 'idle',
|
|
587
|
+
messageBuffer: [],
|
|
588
|
+
messageCursor: 0,
|
|
589
|
+
streamTask: null,
|
|
590
|
+
lastError: null,
|
|
591
|
+
config, // Store config for later use
|
|
592
|
+
isResumed: true, // Resumed from persistence
|
|
593
|
+
backgroundTasks: new Map(),
|
|
594
|
+
sawResult: false,
|
|
595
|
+
enqueuePrompt: null,
|
|
596
|
+
releasePrompt: null,
|
|
597
|
+
};
|
|
598
|
+
this.sessions.set(sessionId, session);
|
|
599
|
+
// Don't start query yet - wait for user to send a message
|
|
600
|
+
// The query will be started in sendPrompt with resume option
|
|
601
|
+
console.log(`[ClaudeCodeAdapter] Session resumed: ${sessionId} (waiting for user prompt)`);
|
|
602
|
+
// Load conversation history from session file
|
|
603
|
+
const messages = await this.loadSessionHistory(sessionId, config.workspaceDir);
|
|
604
|
+
console.log(`[ClaudeCodeAdapter] Session loaded with ${messages.length} messages`);
|
|
605
|
+
return messages;
|
|
606
|
+
}
|
|
607
|
+
async sendPrompt(sessionId, parts, config) {
|
|
608
|
+
const session = this.sessions.get(sessionId);
|
|
609
|
+
if (!session) {
|
|
610
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
611
|
+
}
|
|
612
|
+
if (!ClaudeAgentSDK) {
|
|
613
|
+
throw new Error('Claude Agent SDK not loaded');
|
|
614
|
+
}
|
|
615
|
+
// Extract text from parts
|
|
616
|
+
const promptText = parts
|
|
617
|
+
.filter((p) => p.type === 'text' && p.text)
|
|
618
|
+
.map((p) => p.text)
|
|
619
|
+
.join('\n');
|
|
620
|
+
if (!promptText) {
|
|
621
|
+
throw new Error('No text content in prompt parts');
|
|
622
|
+
}
|
|
623
|
+
// Note: Don't add user message to buffer - agent-manager already shows it
|
|
624
|
+
// to avoid duplicate messages in UI
|
|
625
|
+
// Check if this is the first prompt (no query running yet)
|
|
626
|
+
const isFirstPrompt = !session.queryIterator;
|
|
627
|
+
// A live query owns a persistent prompt stream. Add the next turn to that
|
|
628
|
+
// stream instead of closing and recreating the harness process at every
|
|
629
|
+
// normal idle boundary.
|
|
630
|
+
if (!isFirstPrompt && session.enqueuePrompt) {
|
|
631
|
+
session.sawResult = false;
|
|
632
|
+
session.status = 'busy';
|
|
633
|
+
session.lastError = null;
|
|
634
|
+
session.enqueuePrompt(promptText);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// Recovery path for an old or incomplete live-session state which has no
|
|
638
|
+
// prompt queue. Close that process before creating a correct persistent one.
|
|
639
|
+
if (!isFirstPrompt && session.queryIterator) {
|
|
640
|
+
session.releasePrompt?.();
|
|
641
|
+
session.releasePrompt = null;
|
|
642
|
+
session.enqueuePrompt = null;
|
|
643
|
+
session.abortController?.abort();
|
|
644
|
+
// Wait for stream cleanup to complete to avoid race conditions
|
|
645
|
+
if (session.streamTask) {
|
|
646
|
+
try {
|
|
647
|
+
await session.streamTask;
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// Ignore errors during cleanup (abort errors are expected)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Find Claude executable
|
|
655
|
+
const claudePath = await this.findClaudeExecutable();
|
|
656
|
+
// Create new abort controller
|
|
657
|
+
const abortController = new AbortController();
|
|
658
|
+
session.abortController = abortController;
|
|
659
|
+
// Build options
|
|
660
|
+
const secretHooks = this.buildSecretHooks(config);
|
|
661
|
+
const effort = config.reasoningEffort === 'minimal' ? undefined : config.reasoningEffort;
|
|
662
|
+
const claudePermissionMode = (0, permission_mode_1.claudeCodePermissionMode)(config);
|
|
663
|
+
const options = {
|
|
664
|
+
cwd: config.workspaceDir,
|
|
665
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
666
|
+
env: this.buildClaudeEnvironment(),
|
|
667
|
+
mcpServers: config.mcpServers,
|
|
668
|
+
model: config.model,
|
|
669
|
+
effort,
|
|
670
|
+
systemPrompt: config.systemPrompt,
|
|
671
|
+
abortController,
|
|
672
|
+
/*
|
|
673
|
+
* DEFECT G1 WAS BORN ON THIS LINE.
|
|
674
|
+
*
|
|
675
|
+
* It used to read:
|
|
676
|
+
* permissionMode: 'bypassPermissions', // Auto-approve all actions
|
|
677
|
+
* allowDangerouslySkipPermissions: true,
|
|
678
|
+
*
|
|
679
|
+
* — a hardcode, with the per-agent `permission_mode` sitting unread in
|
|
680
|
+
* the config that was passed in. Every agent ran fully unrestricted no
|
|
681
|
+
* matter what the user chose, while the UI showed them their choice.
|
|
682
|
+
*
|
|
683
|
+
* The comment justified it with "the user has already chosen to run
|
|
684
|
+
* agent". That was true of 20x's ORIGINAL shape, where the agent ran on
|
|
685
|
+
* the user's own laptop with the user watching. It stopped being true the
|
|
686
|
+
* moment the same adapter ran unattended in a cloud sandbox with a real
|
|
687
|
+
* filesystem, which is the whole point of the sandbox.
|
|
688
|
+
*
|
|
689
|
+
* `claudeCodePermissionMode` reconstructs the user's original four-valued
|
|
690
|
+
* choice from the (permissionMode, sandboxMode) pair the runner mapped it
|
|
691
|
+
* onto — see runtime/permission-mode.ts for the table. Claude Code is the
|
|
692
|
+
* one backend that takes all four natively, so nothing is lost here.
|
|
693
|
+
*
|
|
694
|
+
* A config with NO permissionMode maps to 'default', NOT to
|
|
695
|
+
* 'bypassPermissions'. That is deliberate: the old behaviour must not be
|
|
696
|
+
* reachable by omission, or every caller that forgets the field silently
|
|
697
|
+
* restores the defect.
|
|
698
|
+
*/
|
|
699
|
+
permissionMode: claudePermissionMode,
|
|
700
|
+
// ONLY for the mode that actually asks for it. The SDK requires this flag
|
|
701
|
+
// to honour 'bypassPermissions'; passing it unconditionally would make
|
|
702
|
+
// the mode above decorative in exactly the way G1 was.
|
|
703
|
+
...(claudePermissionMode === 'bypassPermissions'
|
|
704
|
+
? { allowDangerouslySkipPermissions: true }
|
|
705
|
+
: {}),
|
|
706
|
+
...(secretHooks ? { hooks: secretHooks } : {}),
|
|
707
|
+
};
|
|
708
|
+
// Determine session continuation mode
|
|
709
|
+
if (isFirstPrompt && session.isResumed) {
|
|
710
|
+
// First prompt after resume: use resume to load persisted session
|
|
711
|
+
options.resume = sessionId;
|
|
712
|
+
// Don't use continue with resume - they're mutually exclusive
|
|
713
|
+
}
|
|
714
|
+
else if (isFirstPrompt && session.sessionId) {
|
|
715
|
+
// Process exited (error recovery or idle timeout) but session has a
|
|
716
|
+
// valid Claude Code UUID. Resume from persistence so the agent keeps
|
|
717
|
+
// its full conversation history instead of starting from scratch.
|
|
718
|
+
options.resume = session.sessionId;
|
|
719
|
+
}
|
|
720
|
+
else if (!isFirstPrompt) {
|
|
721
|
+
// Subsequent prompts: continue existing session in same process
|
|
722
|
+
options.continue = true;
|
|
723
|
+
}
|
|
724
|
+
// Otherwise: brand-new session, no continue/resume needed
|
|
725
|
+
console.log('[ClaudeCodeAdapter] Starting query with options:', {
|
|
726
|
+
cwd: options.cwd,
|
|
727
|
+
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
|
728
|
+
model: options.model,
|
|
729
|
+
effort: options.effort,
|
|
730
|
+
permissionMode: options.permissionMode,
|
|
731
|
+
continue: options.continue,
|
|
732
|
+
resume: options.resume,
|
|
733
|
+
isFirstPrompt,
|
|
734
|
+
isResumed: session.isResumed,
|
|
735
|
+
promptLength: promptText.length,
|
|
736
|
+
promptPreview: promptText.substring(0, 100),
|
|
737
|
+
hasEnv: !!options.env,
|
|
738
|
+
hasMcpServers: !!options.mcpServers,
|
|
739
|
+
mcpServerNames: options.mcpServers ? Object.keys(options.mcpServers) : [],
|
|
740
|
+
});
|
|
741
|
+
// ── Streaming-input prompt ──
|
|
742
|
+
// Passing a plain string puts the Claude Code CLI in one-shot mode: it closes
|
|
743
|
+
// stdin, and as soon as the turn emits `result` the process tears down and
|
|
744
|
+
// KILLS any background task still in flight (observed as
|
|
745
|
+
// `task_updated {status:'killed'}` / `task_notification {status:'stopped'}`).
|
|
746
|
+
// Since Claude Code backgrounds Task-tool subagents by default, that silently
|
|
747
|
+
// killed subagents the moment the coordinator's turn went quiet.
|
|
748
|
+
//
|
|
749
|
+
// The queue stays open across normal idle boundaries. This matches the
|
|
750
|
+
// lifecycle used by the other push-based harness adapters: polling can stop,
|
|
751
|
+
// while the provider event stream remains subscribed and can wake it later.
|
|
752
|
+
const promptQueue = [promptText];
|
|
753
|
+
let wakePromptReader = null;
|
|
754
|
+
let promptStreamClosed = false;
|
|
755
|
+
session.enqueuePrompt = (nextPrompt) => {
|
|
756
|
+
if (promptStreamClosed)
|
|
757
|
+
return;
|
|
758
|
+
promptQueue.push(nextPrompt);
|
|
759
|
+
wakePromptReader?.();
|
|
760
|
+
wakePromptReader = null;
|
|
761
|
+
};
|
|
762
|
+
session.releasePrompt = () => {
|
|
763
|
+
if (promptStreamClosed)
|
|
764
|
+
return;
|
|
765
|
+
promptStreamClosed = true;
|
|
766
|
+
wakePromptReader?.();
|
|
767
|
+
wakePromptReader = null;
|
|
768
|
+
};
|
|
769
|
+
session.backgroundTasks.clear();
|
|
770
|
+
session.sawResult = false;
|
|
771
|
+
const promptStream = (async function* () {
|
|
772
|
+
while (!promptStreamClosed) {
|
|
773
|
+
if (promptQueue.length === 0) {
|
|
774
|
+
await new Promise((resolve) => {
|
|
775
|
+
wakePromptReader = resolve;
|
|
776
|
+
});
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
const nextPrompt = promptQueue.shift();
|
|
780
|
+
yield {
|
|
781
|
+
type: 'user',
|
|
782
|
+
message: { role: 'user', content: nextPrompt },
|
|
783
|
+
parent_tool_use_id: null,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
})();
|
|
787
|
+
// Start new query
|
|
788
|
+
const query = ClaudeAgentSDK.query({
|
|
789
|
+
prompt: promptStream,
|
|
790
|
+
options,
|
|
791
|
+
});
|
|
792
|
+
console.log('[ClaudeCodeAdapter] Query created, starting stream consumption');
|
|
793
|
+
session.queryIterator = query;
|
|
794
|
+
session.status = 'busy';
|
|
795
|
+
session.lastError = null; // Clear any previous error (e.g., rate limit) for recovery
|
|
796
|
+
if (!isFirstPrompt) {
|
|
797
|
+
session.messageBuffer = []; // Clear buffer for new messages (but keep history for first prompt)
|
|
798
|
+
session.messageCursor = 0;
|
|
799
|
+
}
|
|
800
|
+
// After first prompt in a resumed session, clear the flag so subsequent prompts use continue
|
|
801
|
+
if (isFirstPrompt && session.isResumed) {
|
|
802
|
+
session.isResumed = false;
|
|
803
|
+
}
|
|
804
|
+
// Start consuming stream
|
|
805
|
+
session.streamTask = this.consumeStream(sessionId, session);
|
|
806
|
+
}
|
|
807
|
+
async getStatus(sessionId, _config) {
|
|
808
|
+
const session = this.sessions.get(sessionId);
|
|
809
|
+
if (!session) {
|
|
810
|
+
return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: 'Session not found' };
|
|
811
|
+
}
|
|
812
|
+
if (session.lastError) {
|
|
813
|
+
return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: session.lastError };
|
|
814
|
+
}
|
|
815
|
+
// getStatus is the 2s poll path, so it is also where a stalled background
|
|
816
|
+
// task gets aged out and a turn that finished between stream messages gets
|
|
817
|
+
// settled — otherwise a lost terminal notification would pin BUSY forever.
|
|
818
|
+
this.settleTurnIfComplete(sessionId, session);
|
|
819
|
+
// A turn that ended while subagents are still running in the background is
|
|
820
|
+
// paused, not finished. Reporting IDLE here makes agent-manager mark the task
|
|
821
|
+
// ready_for_review, unregister it from polling (so the subagents' output can
|
|
822
|
+
// never be delivered) and eventually reap the session — killing them.
|
|
823
|
+
if (session.backgroundTasks.size > 0) {
|
|
824
|
+
return { type: coding_agent_adapter_1.SessionStatusType.BUSY };
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
type: session.status === 'busy' ? coding_agent_adapter_1.SessionStatusType.BUSY : coding_agent_adapter_1.SessionStatusType.IDLE,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Exposes in-flight background subagents as "running tools" so agent-manager's
|
|
832
|
+
* delegation-aware watchdogs (which look for tool names like `task`/`agent`)
|
|
833
|
+
* stand down instead of aborting a coordinator that is waiting on children.
|
|
834
|
+
*/
|
|
835
|
+
async getRunningTools(sessionId, _config) {
|
|
836
|
+
const session = this.sessions.get(sessionId);
|
|
837
|
+
if (!session)
|
|
838
|
+
return [];
|
|
839
|
+
// Every entry is reported as `task` — a name in agent-manager's
|
|
840
|
+
// DELEGATION_TOOL_NAMES set. Backgrounded work (subagent *and* bash) is
|
|
841
|
+
// long-running by design, so it must be exempt from BOTH the 90s stuck-tool
|
|
842
|
+
// detector and the 5min stuck-session watchdog; naming a backgrounded bash
|
|
843
|
+
// 'bash' would instead get the session aborted after 90 seconds.
|
|
844
|
+
return [...session.backgroundTasks.values()].map((t) => ({
|
|
845
|
+
partId: `task-${t.taskId}`,
|
|
846
|
+
toolName: 'task',
|
|
847
|
+
startTime: t.startedAt,
|
|
848
|
+
input: { taskType: t.taskType ?? 'unknown', description: t.description ?? '' },
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
async pollMessages(sessionId, _seenMessageIds, seenPartIds, partContentLengths, _config) {
|
|
852
|
+
const session = this.sessions.get(sessionId);
|
|
853
|
+
if (!session) {
|
|
854
|
+
return [];
|
|
855
|
+
}
|
|
856
|
+
const newParts = [];
|
|
857
|
+
// Process only NEW buffered messages using cursor (avoids re-scanning entire buffer)
|
|
858
|
+
const bufferLen = session.messageBuffer.length;
|
|
859
|
+
for (let i = session.messageCursor; i < bufferLen; i++) {
|
|
860
|
+
const sdkMsg = session.messageBuffer[i];
|
|
861
|
+
const msgId = this.getMessageId(sdkMsg);
|
|
862
|
+
if (!msgId)
|
|
863
|
+
continue;
|
|
864
|
+
// Convert SDKMessage to MessagePart
|
|
865
|
+
const parts = this.convertSDKMessageToParts(sdkMsg, seenPartIds, partContentLengths);
|
|
866
|
+
newParts.push(...parts);
|
|
867
|
+
}
|
|
868
|
+
// Advance cursor past processed messages
|
|
869
|
+
session.messageCursor = bufferLen;
|
|
870
|
+
// If we have the real Claude session ID and it's different from the map key,
|
|
871
|
+
// include it in the first part so agent-manager can update the database
|
|
872
|
+
if (session.sessionId && session.sessionId !== sessionId && newParts.length > 0) {
|
|
873
|
+
newParts[0].realSessionId = session.sessionId;
|
|
874
|
+
}
|
|
875
|
+
return newParts;
|
|
876
|
+
}
|
|
877
|
+
async abortPrompt(sessionId, _config) {
|
|
878
|
+
const session = this.sessions.get(sessionId);
|
|
879
|
+
if (!session) {
|
|
880
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
881
|
+
}
|
|
882
|
+
console.log(`[ClaudeCodeAdapter] Aborting session ${sessionId}`);
|
|
883
|
+
// Close the streaming-input prompt first so the generator can't keep the
|
|
884
|
+
// process alive while we wait for the stream task to unwind.
|
|
885
|
+
session.releasePrompt?.();
|
|
886
|
+
session.releasePrompt = null;
|
|
887
|
+
session.enqueuePrompt = null;
|
|
888
|
+
session.backgroundTasks.clear();
|
|
889
|
+
session.abortController?.abort();
|
|
890
|
+
// Wait for stream to finish cleanup
|
|
891
|
+
if (session.streamTask) {
|
|
892
|
+
console.log(`[ClaudeCodeAdapter] Waiting for stream cleanup...`);
|
|
893
|
+
try {
|
|
894
|
+
await session.streamTask;
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
// Ignore abort errors
|
|
898
|
+
}
|
|
899
|
+
console.log(`[ClaudeCodeAdapter] Stream cleanup complete`);
|
|
900
|
+
}
|
|
901
|
+
session.status = 'idle';
|
|
902
|
+
console.log(`[ClaudeCodeAdapter] Session ${sessionId} aborted and set to idle`);
|
|
903
|
+
}
|
|
904
|
+
async destroySession(sessionId, _config) {
|
|
905
|
+
const session = this.sessions.get(sessionId);
|
|
906
|
+
if (!session) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// Abort any ongoing query
|
|
910
|
+
session.releasePrompt?.();
|
|
911
|
+
session.releasePrompt = null;
|
|
912
|
+
session.enqueuePrompt = null;
|
|
913
|
+
session.backgroundTasks.clear();
|
|
914
|
+
session.abortController?.abort();
|
|
915
|
+
// Wait for stream task to complete
|
|
916
|
+
if (session.streamTask) {
|
|
917
|
+
try {
|
|
918
|
+
await session.streamTask;
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
// Ignore errors during cleanup
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// Remove all references to this session (both temp ID and real ID)
|
|
925
|
+
// Since re-keying keeps both keys, we need to clean up both
|
|
926
|
+
const keysToDelete = [];
|
|
927
|
+
for (const [key, sess] of this.sessions.entries()) {
|
|
928
|
+
if (sess === session) {
|
|
929
|
+
keysToDelete.push(key);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const key of keysToDelete) {
|
|
933
|
+
this.sessions.delete(key);
|
|
934
|
+
console.log(`[ClaudeCodeAdapter] Removed session key: ${key}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
async getAllMessages(sessionId, _config) {
|
|
938
|
+
const session = this.sessions.get(sessionId);
|
|
939
|
+
if (!session) {
|
|
940
|
+
return [];
|
|
941
|
+
}
|
|
942
|
+
// Reuse the same convertSDKMessageToParts() that the live streaming path uses.
|
|
943
|
+
// This ensures replay produces the same message structure (IDs, content, tool
|
|
944
|
+
// fields) as the original live stream, which is critical for:
|
|
945
|
+
// - Correct content extraction from nested message.content[] arrays
|
|
946
|
+
// - Proper tool names (tool_name vs name), statuses, and titles
|
|
947
|
+
// - Consistent part IDs so mobile dedup works on reconnect
|
|
948
|
+
const seenPartIds = new Set();
|
|
949
|
+
const partContentLengths = new Map();
|
|
950
|
+
const messages = [];
|
|
951
|
+
let currentMessage = null;
|
|
952
|
+
let messageIdCounter = 0;
|
|
953
|
+
for (const msg of session.messageBuffer) {
|
|
954
|
+
// Derive role from message type
|
|
955
|
+
const msgRecord = msg;
|
|
956
|
+
const roleStr = (msgRecord.role || (msg.type === 'user' ? 'user' : 'assistant'));
|
|
957
|
+
const role = roleStr === 'user' ? coding_agent_adapter_1.MessageRole.USER :
|
|
958
|
+
roleStr === 'system' ? coding_agent_adapter_1.MessageRole.SYSTEM :
|
|
959
|
+
coding_agent_adapter_1.MessageRole.ASSISTANT;
|
|
960
|
+
// Convert using the full SDK message parser
|
|
961
|
+
const parts = this.convertSDKMessageToParts(msg, seenPartIds, partContentLengths);
|
|
962
|
+
if (parts.length === 0)
|
|
963
|
+
continue;
|
|
964
|
+
// Start new message if role changed
|
|
965
|
+
if (!currentMessage || currentMessage.role !== role) {
|
|
966
|
+
if (currentMessage) {
|
|
967
|
+
messages.push(currentMessage);
|
|
968
|
+
}
|
|
969
|
+
currentMessage = {
|
|
970
|
+
id: `msg-${messageIdCounter++}`,
|
|
971
|
+
role,
|
|
972
|
+
parts: []
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
currentMessage.parts.push(...parts);
|
|
976
|
+
}
|
|
977
|
+
// Push last message
|
|
978
|
+
if (currentMessage) {
|
|
979
|
+
messages.push(currentMessage);
|
|
980
|
+
}
|
|
981
|
+
return messages;
|
|
982
|
+
}
|
|
983
|
+
async respondToQuestion(sessionId, answers, config) {
|
|
984
|
+
// Claude Code runs with bypassPermissions, so AskUserQuestion ends the
|
|
985
|
+
// session turn (it appears in permission_denials in the result message).
|
|
986
|
+
// Send the user's answer as a follow-up prompt to continue the conversation.
|
|
987
|
+
const answerText = Object.values(answers).filter(Boolean).join('\n');
|
|
988
|
+
if (!answerText) {
|
|
989
|
+
console.warn(`[ClaudeCodeAdapter] respondToQuestion called with empty answers for session ${sessionId}`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
console.log(`[ClaudeCodeAdapter] Sending question answer as follow-up prompt for session ${sessionId}`);
|
|
993
|
+
await this.sendPrompt(sessionId, [{ type: coding_agent_adapter_1.MessagePartType.TEXT, text: answerText }], config);
|
|
994
|
+
}
|
|
995
|
+
async registerMcpServer(_serverName, _mcpConfig, _workspaceDir) {
|
|
996
|
+
// Claude Code handles MCP servers via the mcpServers option
|
|
997
|
+
// They're passed per-session in createSession/resumeSession/sendPrompt
|
|
998
|
+
// No global registration needed
|
|
999
|
+
}
|
|
1000
|
+
async checkHealth() {
|
|
1001
|
+
try {
|
|
1002
|
+
await this.ensureSDKLoaded();
|
|
1003
|
+
if (!ClaudeAgentSDK) {
|
|
1004
|
+
return { available: false, reason: 'Claude Agent SDK not loaded' };
|
|
1005
|
+
}
|
|
1006
|
+
return { available: true };
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
return { available: false, reason: error instanceof Error ? error.message : String(error) };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// ========================================================================
|
|
1013
|
+
// Private Helper Methods
|
|
1014
|
+
// ========================================================================
|
|
1015
|
+
/**
|
|
1016
|
+
* Safely logs a message by truncating large base64 content
|
|
1017
|
+
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* Lightweight message logger — only logs type/subtype to avoid blocking
|
|
1020
|
+
* the event loop with expensive JSON serialization on every streaming chunk.
|
|
1021
|
+
*/
|
|
1022
|
+
safeLogMessage(msg) {
|
|
1023
|
+
if (!msg || typeof msg !== 'object')
|
|
1024
|
+
return;
|
|
1025
|
+
const m = msg;
|
|
1026
|
+
console.log(`[ClaudeCodeAdapter] msg: type=${m.type} subtype=${m.subtype || '-'}`);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Consumes the query stream in the background and buffers messages
|
|
1030
|
+
*/
|
|
1031
|
+
async consumeStream(sessionId, session) {
|
|
1032
|
+
if (!session.queryIterator)
|
|
1033
|
+
return;
|
|
1034
|
+
console.log('[ClaudeCodeAdapter] Starting stream consumption');
|
|
1035
|
+
try {
|
|
1036
|
+
let messagesSinceYield = 0;
|
|
1037
|
+
for await (const message of session.queryIterator) {
|
|
1038
|
+
// Guard against undefined/null messages from the SDK (can happen during
|
|
1039
|
+
// process crashes, lock acquisition failures, or SDK bugs)
|
|
1040
|
+
if (!message || typeof message !== 'object') {
|
|
1041
|
+
console.warn('[ClaudeCodeAdapter] Received invalid message from SDK, skipping:', typeof message);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const msg = message;
|
|
1045
|
+
// ── Prevent microtask starvation ──
|
|
1046
|
+
// When the subprocess sends a burst of messages, the async iterator
|
|
1047
|
+
// resolves each next() as a microtask without ever yielding to the
|
|
1048
|
+
// macrotask queue. This starves IPC, timers, and rendering callbacks,
|
|
1049
|
+
// making the UI completely unresponsive (loading cursor).
|
|
1050
|
+
// Yield every 5 messages so the event loop can process I/O.
|
|
1051
|
+
messagesSinceYield++;
|
|
1052
|
+
if (messagesSinceYield >= 5) {
|
|
1053
|
+
messagesSinceYield = 0;
|
|
1054
|
+
await new Promise((r) => setImmediate(r));
|
|
1055
|
+
}
|
|
1056
|
+
// Log message with truncation for large content
|
|
1057
|
+
this.safeLogMessage(message);
|
|
1058
|
+
// Extract real Claude Code session ID from first message
|
|
1059
|
+
if (!session.sessionId && 'session_id' in msg) {
|
|
1060
|
+
const realSessionId = msg.session_id;
|
|
1061
|
+
session.sessionId = realSessionId;
|
|
1062
|
+
console.log(`[ClaudeCodeAdapter] Claude Code session ID: ${realSessionId}`);
|
|
1063
|
+
// Add the session under the real ID (keep old ID too until agent-manager updates)
|
|
1064
|
+
// This allows pollMessages to work with both IDs during transition
|
|
1065
|
+
if (realSessionId !== sessionId) {
|
|
1066
|
+
console.log(`[ClaudeCodeAdapter] Adding session under real ID: ${realSessionId} (keeping temp ID ${sessionId} until agent-manager updates)`);
|
|
1067
|
+
this.sessions.set(realSessionId, session);
|
|
1068
|
+
// Don't delete the old sessionId yet - agent-manager needs to poll with it
|
|
1069
|
+
// to receive the realSessionId. The old key will be deleted by destroySession
|
|
1070
|
+
// or when agent-manager explicitly removes it.
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// Check for session not found error BEFORE buffering
|
|
1074
|
+
if (msg.type === 'result' && msg.subtype === 'error_during_execution' && msg.is_error) {
|
|
1075
|
+
const errors = Array.isArray(msg.errors) ? msg.errors : [];
|
|
1076
|
+
const sessionNotFound = errors.some((err) => err.includes('No conversation found') || err.includes('session ID'));
|
|
1077
|
+
if (sessionNotFound) {
|
|
1078
|
+
console.warn('[ClaudeCodeAdapter] Session not found on Claude Code server:', errors);
|
|
1079
|
+
// Don't buffer this error message - throw immediately
|
|
1080
|
+
throw new Error('INCOMPATIBLE_SESSION_ID: This session does not exist on Claude Code servers. It may have been created with a different coding agent or has expired.');
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// Handle error result messages (e.g., rate limits) before treating as normal
|
|
1084
|
+
if (msg.type === 'result' && msg.is_error) {
|
|
1085
|
+
const raw = msg;
|
|
1086
|
+
// Extract meaningful error text from all available fields:
|
|
1087
|
+
// - `result` may be a string or object
|
|
1088
|
+
// - `errors` may be an array of error strings
|
|
1089
|
+
// - `error` may be a string
|
|
1090
|
+
const resultField = raw.result;
|
|
1091
|
+
const errorsField = Array.isArray(raw.errors) ? raw.errors : [];
|
|
1092
|
+
const errorField = typeof raw.error === 'string' ? raw.error : '';
|
|
1093
|
+
const subtypeField = typeof raw.subtype === 'string' ? raw.subtype : '';
|
|
1094
|
+
let errorText;
|
|
1095
|
+
if (typeof resultField === 'string' && resultField.length > 0) {
|
|
1096
|
+
errorText = resultField;
|
|
1097
|
+
}
|
|
1098
|
+
else if (errorsField.length > 0) {
|
|
1099
|
+
errorText = errorsField.map(String).join('; ');
|
|
1100
|
+
}
|
|
1101
|
+
else if (errorField) {
|
|
1102
|
+
errorText = errorField;
|
|
1103
|
+
}
|
|
1104
|
+
else if (resultField && typeof resultField === 'object') {
|
|
1105
|
+
errorText = JSON.stringify(resultField);
|
|
1106
|
+
}
|
|
1107
|
+
else if (subtypeField) {
|
|
1108
|
+
errorText = `Error during ${subtypeField}`;
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
errorText = 'Unknown error (no details in result message)';
|
|
1112
|
+
}
|
|
1113
|
+
console.warn('[ClaudeCodeAdapter] Received error result:', errorText);
|
|
1114
|
+
console.warn('[ClaudeCodeAdapter] Full error result message:', JSON.stringify(raw, null, 2));
|
|
1115
|
+
session.status = 'error';
|
|
1116
|
+
session.lastError = errorText;
|
|
1117
|
+
}
|
|
1118
|
+
if (msg.type === 'assistant' && msg.isApiErrorMessage === true) {
|
|
1119
|
+
const raw = msg;
|
|
1120
|
+
const messageField = raw.message;
|
|
1121
|
+
const contentField = Array.isArray(messageField?.content)
|
|
1122
|
+
? messageField.content
|
|
1123
|
+
: Array.isArray(raw.content) ? raw.content : [];
|
|
1124
|
+
const text = contentField
|
|
1125
|
+
.map((part) => {
|
|
1126
|
+
const partRecord = part;
|
|
1127
|
+
return partRecord.type === 'text' ? partRecord.text || '' : '';
|
|
1128
|
+
})
|
|
1129
|
+
.filter(Boolean)
|
|
1130
|
+
.join('\n');
|
|
1131
|
+
session.status = 'error';
|
|
1132
|
+
session.lastError = text || 'Claude Code API error';
|
|
1133
|
+
}
|
|
1134
|
+
// Buffer message (only if we didn't throw above)
|
|
1135
|
+
session.messageBuffer.push(message);
|
|
1136
|
+
// Cap buffer size to prevent unbounded memory growth.
|
|
1137
|
+
// Drop already-processed messages from the front when limit is exceeded.
|
|
1138
|
+
if (session.messageBuffer.length > MAX_MESSAGE_BUFFER_SIZE) {
|
|
1139
|
+
const drop = session.messageBuffer.length - MAX_MESSAGE_BUFFER_SIZE;
|
|
1140
|
+
session.messageBuffer.splice(0, drop);
|
|
1141
|
+
session.messageCursor = Math.max(0, session.messageCursor - drop);
|
|
1142
|
+
}
|
|
1143
|
+
// Notify the polling coordinator that new data is available so it can
|
|
1144
|
+
// deliver this message to the UI immediately instead of waiting for
|
|
1145
|
+
// the next 2-second heartbeat tick.
|
|
1146
|
+
if (this.onDataAvailable) {
|
|
1147
|
+
this.onDataAvailable(sessionId);
|
|
1148
|
+
}
|
|
1149
|
+
// Track background subagent/bash tasks so we never report the session as
|
|
1150
|
+
// finished while children are still running.
|
|
1151
|
+
this.trackBackgroundTask(sessionId, session, message);
|
|
1152
|
+
// Update status based on message type
|
|
1153
|
+
if (msg.type === 'status') {
|
|
1154
|
+
console.log(`[ClaudeCodeAdapter] Status update: ${msg.subtype}`);
|
|
1155
|
+
if (msg.subtype === 'busy') {
|
|
1156
|
+
session.status = 'busy';
|
|
1157
|
+
session.sawResult = false;
|
|
1158
|
+
}
|
|
1159
|
+
else if (msg.subtype === 'idle') {
|
|
1160
|
+
session.status = 'idle';
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
else if (msg.type === 'result' && !msg.is_error) {
|
|
1164
|
+
console.log('[ClaudeCodeAdapter] Received result message');
|
|
1165
|
+
session.sawResult = true;
|
|
1166
|
+
}
|
|
1167
|
+
else if (msg.type === 'assistant' || msg.type === 'user') {
|
|
1168
|
+
// A new turn started (e.g. a background task woke the session back up)
|
|
1169
|
+
session.sawResult = false;
|
|
1170
|
+
}
|
|
1171
|
+
// The turn is only really over when `result` arrived AND nothing is left
|
|
1172
|
+
// running in the background.
|
|
1173
|
+
this.settleTurnIfComplete(sessionId, session);
|
|
1174
|
+
// Log stderr/stdout if present
|
|
1175
|
+
if (msg.type === 'stream_event' && msg.stderr) {
|
|
1176
|
+
console.error('[ClaudeCodeAdapter] Claude stderr:', msg.stderr);
|
|
1177
|
+
}
|
|
1178
|
+
if (msg.type === 'stream_event' && msg.stdout) {
|
|
1179
|
+
console.log('[ClaudeCodeAdapter] Claude stdout:', msg.stdout);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
console.log('[ClaudeCodeAdapter] Stream consumption completed normally');
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
const errName = error instanceof Error ? error.name : '';
|
|
1186
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1187
|
+
const errStack = error instanceof Error ? error.stack : undefined;
|
|
1188
|
+
if (errName === 'AbortError' || errMsg.includes('aborted by user')) {
|
|
1189
|
+
console.log('[ClaudeCodeAdapter] Stream aborted by user');
|
|
1190
|
+
// Don't set error status - this is normal when sending a new message
|
|
1191
|
+
}
|
|
1192
|
+
else if (errMsg.includes('INCOMPATIBLE_SESSION_ID')) {
|
|
1193
|
+
// Store temporarily so resumeSession can detect and re-throw it
|
|
1194
|
+
console.warn('[ClaudeCodeAdapter] Incompatible session error detected');
|
|
1195
|
+
session.status = 'error';
|
|
1196
|
+
session.lastError = errMsg;
|
|
1197
|
+
}
|
|
1198
|
+
else if (errMsg.includes('exited with code 1')) {
|
|
1199
|
+
// Claude Code process failed - could be rate limit, resume failure, or other error
|
|
1200
|
+
console.error('[ClaudeCodeAdapter] Claude Code process failed:', errMsg);
|
|
1201
|
+
// Only treat as incompatible session if this was a resumed session AND we
|
|
1202
|
+
// don't already have a specific error from the result message (e.g., rate limits).
|
|
1203
|
+
// Note: session.config is always set, so only check session.isResumed here.
|
|
1204
|
+
if (!session.lastError && session.isResumed) {
|
|
1205
|
+
console.warn('[ClaudeCodeAdapter] Resume failed - session may not exist on Claude servers');
|
|
1206
|
+
session.status = 'error';
|
|
1207
|
+
session.lastError = 'INCOMPATIBLE_SESSION_ID: Failed to resume session. The session may have expired or does not exist on Claude Code servers.';
|
|
1208
|
+
}
|
|
1209
|
+
else if (!session.lastError) {
|
|
1210
|
+
session.status = 'error';
|
|
1211
|
+
session.lastError = errMsg;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
console.error('[ClaudeCodeAdapter] Stream error:', error);
|
|
1216
|
+
console.error('[ClaudeCodeAdapter] Error stack:', errStack);
|
|
1217
|
+
console.error('[ClaudeCodeAdapter] Error details:', JSON.stringify(error, error instanceof Error ? Object.getOwnPropertyNames(error) : undefined, 2));
|
|
1218
|
+
session.status = 'error';
|
|
1219
|
+
session.lastError = errMsg;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
finally {
|
|
1223
|
+
console.log('[ClaudeCodeAdapter] Stream consumption ended');
|
|
1224
|
+
// Always reset queryIterator when the stream ends (process exited).
|
|
1225
|
+
// Previously only error paths did this, leaving a truthy-but-exhausted
|
|
1226
|
+
// iterator after normal completion. That caused the next sendPrompt to
|
|
1227
|
+
// use `--continue` (most-recent conversation in directory) instead of
|
|
1228
|
+
// `--resume <sessionId>` (exact session). If any other session
|
|
1229
|
+
// (heartbeat, subtask) ran in the same directory during idle, --continue
|
|
1230
|
+
// would pick up the wrong conversation — the intermittent context-loss bug.
|
|
1231
|
+
session.queryIterator = null;
|
|
1232
|
+
// The process is gone, so nothing can still be running in the background.
|
|
1233
|
+
session.backgroundTasks.clear();
|
|
1234
|
+
session.releasePrompt?.();
|
|
1235
|
+
session.releasePrompt = null;
|
|
1236
|
+
session.enqueuePrompt = null;
|
|
1237
|
+
if (session.status === 'error') {
|
|
1238
|
+
// Error path: queryIterator already null above; no extra work needed.
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
session.status = 'idle';
|
|
1242
|
+
// Mark as resumed so the next sendPrompt uses --resume with the exact
|
|
1243
|
+
// session ID rather than --continue (which targets most-recent in dir).
|
|
1244
|
+
session.isResumed = true;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Maintains `session.backgroundTasks` from the SDK's task lifecycle messages.
|
|
1250
|
+
*
|
|
1251
|
+
* Note: the raw CLI also emits `background_tasks_changed` (which carries the
|
|
1252
|
+
* authoritative in-flight list), but the SDK filters it out — it is not part of
|
|
1253
|
+
* the `SDKMessage` union — so the set has to be rebuilt from task_started plus
|
|
1254
|
+
* task_updated / task_notification.
|
|
1255
|
+
*/
|
|
1256
|
+
trackBackgroundTask(sessionId, session, message) {
|
|
1257
|
+
const msg = message;
|
|
1258
|
+
if (msg.type !== 'system')
|
|
1259
|
+
return;
|
|
1260
|
+
// `background_tasks_changed` carries the CLI's authoritative in-flight list.
|
|
1261
|
+
// SDK >= 0.3.x filters it out (verified against 0.3.169 and 0.3.195), but the
|
|
1262
|
+
// app has historically bundled older SDKs that do pass it through — prefer it
|
|
1263
|
+
// when available, since it cannot drift the way reconstruction can.
|
|
1264
|
+
if (msg.subtype === ClaudeSystemSubtype.BACKGROUND_TASKS_CHANGED && Array.isArray(msg.tasks)) {
|
|
1265
|
+
const next = new Map();
|
|
1266
|
+
for (const t of msg.tasks) {
|
|
1267
|
+
if (!t?.task_id)
|
|
1268
|
+
continue;
|
|
1269
|
+
const existing = session.backgroundTasks.get(t.task_id);
|
|
1270
|
+
next.set(t.task_id, {
|
|
1271
|
+
taskId: t.task_id,
|
|
1272
|
+
taskType: t.task_type ?? existing?.taskType,
|
|
1273
|
+
description: t.description ?? existing?.description,
|
|
1274
|
+
// Preserve the original start time so the staleness cap stays meaningful.
|
|
1275
|
+
startedAt: existing?.startedAt ?? Date.now(),
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
session.backgroundTasks = next;
|
|
1279
|
+
console.log(`[ClaudeCodeAdapter] Background task list for ${sessionId} refreshed from ` +
|
|
1280
|
+
`background_tasks_changed — ${next.size} in flight`);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (!msg.task_id)
|
|
1284
|
+
return;
|
|
1285
|
+
if (msg.subtype === ClaudeSystemSubtype.TASK_STARTED) {
|
|
1286
|
+
session.backgroundTasks.set(msg.task_id, {
|
|
1287
|
+
taskId: msg.task_id,
|
|
1288
|
+
taskType: msg.task_type || (msg.subagent_type ? 'local_agent' : undefined),
|
|
1289
|
+
description: msg.description,
|
|
1290
|
+
startedAt: Date.now(),
|
|
1291
|
+
});
|
|
1292
|
+
console.log(`[ClaudeCodeAdapter] Background task started for ${sessionId}: ${msg.task_id} ` +
|
|
1293
|
+
`(${msg.task_type || 'unknown'}) — ${session.backgroundTasks.size} in flight`);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const terminalStatus = msg.subtype === ClaudeSystemSubtype.TASK_NOTIFICATION ? msg.status :
|
|
1297
|
+
msg.subtype === ClaudeSystemSubtype.TASK_UPDATED ? msg.patch?.status :
|
|
1298
|
+
undefined;
|
|
1299
|
+
if (terminalStatus && TERMINAL_TASK_STATUSES.has(terminalStatus)) {
|
|
1300
|
+
if (session.backgroundTasks.delete(msg.task_id)) {
|
|
1301
|
+
console.log(`[ClaudeCodeAdapter] Background task ${msg.task_id} ${terminalStatus} for ${sessionId} — ` +
|
|
1302
|
+
`${session.backgroundTasks.size} still in flight`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Drops background tasks that have outlived MAX_BACKGROUND_TASK_AGE_MS so a
|
|
1308
|
+
* lost terminal notification can't keep the session BUSY (and un-reapable)
|
|
1309
|
+
* indefinitely.
|
|
1310
|
+
*/
|
|
1311
|
+
pruneStaleBackgroundTasks(sessionId, session) {
|
|
1312
|
+
if (session.backgroundTasks.size === 0)
|
|
1313
|
+
return;
|
|
1314
|
+
const now = Date.now();
|
|
1315
|
+
for (const [taskId, task] of session.backgroundTasks) {
|
|
1316
|
+
if (now - task.startedAt > MAX_BACKGROUND_TASK_AGE_MS) {
|
|
1317
|
+
session.backgroundTasks.delete(taskId);
|
|
1318
|
+
console.warn(`[ClaudeCodeAdapter] Background task ${taskId} for ${sessionId} exceeded ` +
|
|
1319
|
+
`${MAX_BACKGROUND_TASK_AGE_MS / 60000}min without a terminal notification — ` +
|
|
1320
|
+
`no longer counting it as in flight`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Marks the current turn idle once it has produced a `result` AND no
|
|
1326
|
+
* background task is still running. The streaming-input prompt remains open
|
|
1327
|
+
* for the full session lifetime. Until background work is complete,
|
|
1328
|
+
* the session stays BUSY so agent-manager keeps polling instead of calling
|
|
1329
|
+
* transitionToIdle (which flips the task to ready_for_review and eventually
|
|
1330
|
+
* lets the inactivity reaper destroy the session out from under the subagents).
|
|
1331
|
+
*/
|
|
1332
|
+
settleTurnIfComplete(sessionId, session) {
|
|
1333
|
+
if (session.status === 'error')
|
|
1334
|
+
return;
|
|
1335
|
+
this.pruneStaleBackgroundTasks(sessionId, session);
|
|
1336
|
+
if (!session.sawResult)
|
|
1337
|
+
return;
|
|
1338
|
+
if (session.backgroundTasks.size > 0) {
|
|
1339
|
+
// Paused waiting on background work — explicitly NOT idle.
|
|
1340
|
+
session.status = 'busy';
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (session.status !== 'idle') {
|
|
1344
|
+
console.log(`[ClaudeCodeAdapter] Turn complete for ${sessionId}, no background tasks left → idle`);
|
|
1345
|
+
}
|
|
1346
|
+
session.status = 'idle';
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Extracts a unique message ID from SDKMessage
|
|
1350
|
+
*/
|
|
1351
|
+
getMessageId(msg) {
|
|
1352
|
+
if ('uuid' in msg && msg.uuid) {
|
|
1353
|
+
return msg.uuid;
|
|
1354
|
+
}
|
|
1355
|
+
if ('message_id' in msg && msg.message_id) {
|
|
1356
|
+
return msg.message_id;
|
|
1357
|
+
}
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Converts SDKMessage to MessagePart[] format
|
|
1362
|
+
*/
|
|
1363
|
+
convertSDKMessageToParts(msg, seenPartIds, partContentLengths) {
|
|
1364
|
+
const parts = [];
|
|
1365
|
+
const msgWithProps = msg;
|
|
1366
|
+
// ── Skip messages from inside a subtask ──
|
|
1367
|
+
// Messages originating from a subagent task have a non-null parent_tool_use_id.
|
|
1368
|
+
// These would otherwise leak as top-level user messages / tool calls in the
|
|
1369
|
+
// transcript. The task_started / task_progress / task_notification events
|
|
1370
|
+
// already provide the high-level summary, so we suppress the inner messages.
|
|
1371
|
+
// Exception: system messages (task_started, task_progress, task_notification,
|
|
1372
|
+
// status) do NOT carry parent_tool_use_id and must always be processed.
|
|
1373
|
+
if (msgWithProps.parent_tool_use_id) {
|
|
1374
|
+
return parts;
|
|
1375
|
+
}
|
|
1376
|
+
// Handle assistant_message type
|
|
1377
|
+
if (msgWithProps.type === 'assistant' || msgWithProps.type === 'assistant_message') {
|
|
1378
|
+
// Content is nested inside message.content for Claude Code SDK format
|
|
1379
|
+
const content = msgWithProps.message?.content || (Array.isArray(msgWithProps.content) ? msgWithProps.content : []);
|
|
1380
|
+
const partType = msgWithProps.isApiErrorMessage ? coding_agent_adapter_1.MessagePartType.ERROR : coding_agent_adapter_1.MessagePartType.TEXT;
|
|
1381
|
+
// Use the stable API message ID (e.g. msg_01FG7...) for dedup, not the streaming UUID.
|
|
1382
|
+
// Claude Code sends multiple streaming chunks with different UUIDs but the same API message ID
|
|
1383
|
+
// and the same text block, which would otherwise create duplicate text bubbles in the UI.
|
|
1384
|
+
const stableId = msgWithProps.message?.id || msgWithProps.uuid || msgWithProps.type;
|
|
1385
|
+
for (let blockIdx = 0; blockIdx < content.length; blockIdx++) {
|
|
1386
|
+
const block = content[blockIdx];
|
|
1387
|
+
const blockWithProps = block;
|
|
1388
|
+
// For text blocks (no id), use stable message ID + block index for consistent dedup.
|
|
1389
|
+
// For tool_use blocks, blockWithProps.id is the tool_use_id which is already stable.
|
|
1390
|
+
const partId = `${stableId}-${blockWithProps.type}-${blockWithProps.id || blockIdx}`;
|
|
1391
|
+
if (blockWithProps.type === 'text') {
|
|
1392
|
+
const text = blockWithProps.text || '';
|
|
1393
|
+
if (seenPartIds.has(partId)) {
|
|
1394
|
+
// Check if text content has grown since last seen (streaming update).
|
|
1395
|
+
// Without this, the first chunk (possibly empty/partial) gets recorded
|
|
1396
|
+
// and all subsequent chunks with the actual text are silently dropped,
|
|
1397
|
+
// causing missing assistant responses in the UI.
|
|
1398
|
+
const previousLength = partContentLengths.get(partId);
|
|
1399
|
+
if (previousLength !== undefined && String(text.length) !== previousLength && text.length > 0) {
|
|
1400
|
+
partContentLengths.set(partId, String(text.length));
|
|
1401
|
+
parts.push({
|
|
1402
|
+
id: partId,
|
|
1403
|
+
type: partType,
|
|
1404
|
+
text,
|
|
1405
|
+
update: true,
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
seenPartIds.add(partId);
|
|
1411
|
+
partContentLengths.set(partId, String(text.length));
|
|
1412
|
+
parts.push({
|
|
1413
|
+
id: partId,
|
|
1414
|
+
type: partType,
|
|
1415
|
+
text,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
else if (blockWithProps.type === 'thinking') {
|
|
1419
|
+
const thinking = blockWithProps.thinking || '';
|
|
1420
|
+
if (seenPartIds.has(partId)) {
|
|
1421
|
+
const previousLength = partContentLengths.get(partId);
|
|
1422
|
+
if (previousLength !== undefined && String(thinking.length) !== previousLength && thinking.length > 0) {
|
|
1423
|
+
partContentLengths.set(partId, String(thinking.length));
|
|
1424
|
+
parts.push({
|
|
1425
|
+
id: partId,
|
|
1426
|
+
type: coding_agent_adapter_1.MessagePartType.REASONING,
|
|
1427
|
+
text: thinking,
|
|
1428
|
+
role: 'assistant',
|
|
1429
|
+
update: true,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
seenPartIds.add(partId);
|
|
1435
|
+
partContentLengths.set(partId, String(thinking.length));
|
|
1436
|
+
parts.push({
|
|
1437
|
+
id: partId,
|
|
1438
|
+
type: coding_agent_adapter_1.MessagePartType.REASONING,
|
|
1439
|
+
text: thinking,
|
|
1440
|
+
role: 'assistant',
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
else if (seenPartIds.has(partId)) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
else if (blockWithProps.type === 'tool_use') {
|
|
1447
|
+
seenPartIds.add(partId);
|
|
1448
|
+
const toolName = blockWithProps.name || 'unknown';
|
|
1449
|
+
const rawInput = blockWithProps.input;
|
|
1450
|
+
const input = rawInput ? JSON.stringify(rawInput, null, 2) : undefined;
|
|
1451
|
+
const toolUseId = blockWithProps.id || '';
|
|
1452
|
+
// Use tool_use_id as partId so we can update it when result arrives
|
|
1453
|
+
const toolPartId = `tool-${toolUseId}`;
|
|
1454
|
+
if (seenPartIds.has(toolPartId))
|
|
1455
|
+
continue;
|
|
1456
|
+
seenPartIds.add(toolPartId);
|
|
1457
|
+
// Build a human-readable title from tool input (mirrors buildPartPayload for OpenCode)
|
|
1458
|
+
const title = this.buildToolTitle(toolName, rawInput);
|
|
1459
|
+
// Detect AskUserQuestion → render as interactive question
|
|
1460
|
+
let questions = rawInput?.questions;
|
|
1461
|
+
if (typeof questions === 'string') {
|
|
1462
|
+
try {
|
|
1463
|
+
questions = JSON.parse(questions);
|
|
1464
|
+
}
|
|
1465
|
+
catch { }
|
|
1466
|
+
}
|
|
1467
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
1468
|
+
partContentLengths.set(toolPartId, `pending:${toolName}`);
|
|
1469
|
+
parts.push({
|
|
1470
|
+
id: toolPartId,
|
|
1471
|
+
type: 'question',
|
|
1472
|
+
content: title || 'Question',
|
|
1473
|
+
tool: { name: toolName, status: 'pending', title, input, questions },
|
|
1474
|
+
});
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
// Detect TodoWrite → render as todo list
|
|
1478
|
+
let todos = rawInput?.todos;
|
|
1479
|
+
if (typeof todos === 'string') {
|
|
1480
|
+
try {
|
|
1481
|
+
todos = JSON.parse(todos);
|
|
1482
|
+
}
|
|
1483
|
+
catch { }
|
|
1484
|
+
}
|
|
1485
|
+
if (Array.isArray(todos) && todos.length > 0) {
|
|
1486
|
+
// Normalize: ensure each todo has an id (Claude Code SDK omits it)
|
|
1487
|
+
const normalizedTodos = todos.map((t, i) => ({
|
|
1488
|
+
id: t.id || `todo-${i}`,
|
|
1489
|
+
content: t.content || '',
|
|
1490
|
+
status: t.status || 'pending',
|
|
1491
|
+
priority: t.priority,
|
|
1492
|
+
}));
|
|
1493
|
+
partContentLengths.set(toolPartId, `pending:${toolName}`);
|
|
1494
|
+
parts.push({
|
|
1495
|
+
id: toolPartId,
|
|
1496
|
+
type: 'todowrite',
|
|
1497
|
+
content: title || 'Todo List',
|
|
1498
|
+
tool: { name: toolName, status: 'pending', title, input, todos: normalizedTodos },
|
|
1499
|
+
});
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
// Detect EnterPlanMode / ExitPlanMode → render as plan mode indicator
|
|
1503
|
+
// For ExitPlanMode, the plan content is in input.plan (not in tool_result)
|
|
1504
|
+
if (toolName === 'EnterPlanMode' || toolName === 'ExitPlanMode') {
|
|
1505
|
+
const planTitle = toolName === 'EnterPlanMode' ? 'Enter plan mode' : 'Exit plan mode';
|
|
1506
|
+
const planContent = toolName === 'ExitPlanMode' && rawInput?.plan
|
|
1507
|
+
? String(rawInput.plan).slice(0, 50000) : undefined;
|
|
1508
|
+
partContentLengths.set(toolPartId, `pending:${toolName}`);
|
|
1509
|
+
parts.push({
|
|
1510
|
+
id: toolPartId,
|
|
1511
|
+
type: 'planreview',
|
|
1512
|
+
content: planTitle,
|
|
1513
|
+
tool: { name: toolName, status: 'pending', title: planTitle, input, output: planContent },
|
|
1514
|
+
});
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
// Regular tool call
|
|
1518
|
+
partContentLengths.set(toolPartId, `pending:${toolName}`);
|
|
1519
|
+
parts.push({
|
|
1520
|
+
id: toolPartId,
|
|
1521
|
+
type: coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1522
|
+
content: title ? `${toolName} — ${title}` : toolName,
|
|
1523
|
+
tool: { name: toolName, status: 'pending', title, input },
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
else if (msgWithProps.type === 'user' || msgWithProps.type === 'user_message') {
|
|
1529
|
+
// User messages contain tool results or text
|
|
1530
|
+
const content = msgWithProps.message?.content || (Array.isArray(msgWithProps.content) ? msgWithProps.content : []);
|
|
1531
|
+
for (const block of content) {
|
|
1532
|
+
const blockWithProps = block;
|
|
1533
|
+
// Handle user text messages
|
|
1534
|
+
if (blockWithProps.type === 'text' && blockWithProps.text) {
|
|
1535
|
+
const partId = `${msgWithProps.uuid || 'user'}-text`;
|
|
1536
|
+
if (!seenPartIds.has(partId)) {
|
|
1537
|
+
seenPartIds.add(partId);
|
|
1538
|
+
const text = blockWithProps.text;
|
|
1539
|
+
partContentLengths.set(partId, String(text.length));
|
|
1540
|
+
parts.push({
|
|
1541
|
+
id: partId,
|
|
1542
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1543
|
+
text,
|
|
1544
|
+
role: 'user', // User messages
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
else if (blockWithProps.type === 'tool_result' && blockWithProps.tool_use_id) {
|
|
1549
|
+
const toolPartId = `tool-${blockWithProps.tool_use_id}`;
|
|
1550
|
+
const resultContent = blockWithProps.content || '';
|
|
1551
|
+
// Check if we already sent the pending tool call
|
|
1552
|
+
const previousContent = partContentLengths.get(toolPartId);
|
|
1553
|
+
// Plan mode tools should not be truncated (cap at 50K for safety)
|
|
1554
|
+
const isPlanReview = previousContent?.endsWith(':ExitPlanMode') || previousContent?.endsWith(':EnterPlanMode');
|
|
1555
|
+
// Filter out confirmation prompts like "Exit plan mode?" / "Enter plan mode?" — not useful content
|
|
1556
|
+
const sanitizedResult = isPlanReview && /^(exit|enter) plan mode\??$/i.test(resultContent.trim())
|
|
1557
|
+
? '' : resultContent;
|
|
1558
|
+
const outputContent = isPlanReview
|
|
1559
|
+
? sanitizedResult.slice(0, 50000)
|
|
1560
|
+
: resultContent.slice(0, 2000);
|
|
1561
|
+
if (previousContent) {
|
|
1562
|
+
const toolName = previousContent.split(':')[1] || 'tool';
|
|
1563
|
+
// Update the existing tool part - mark as completed
|
|
1564
|
+
partContentLengths.set(toolPartId, `success:${resultContent.length}`);
|
|
1565
|
+
// For plan review: don't send empty output (would overwrite plan from input.plan)
|
|
1566
|
+
parts.push({
|
|
1567
|
+
id: toolPartId,
|
|
1568
|
+
type: isPlanReview ? 'planreview' : coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1569
|
+
content: isPlanReview ? (toolName === 'EnterPlanMode' ? 'Enter plan mode' : 'Exit plan mode') : `Tool completed`,
|
|
1570
|
+
tool: {
|
|
1571
|
+
name: toolName,
|
|
1572
|
+
status: 'success',
|
|
1573
|
+
...(outputContent ? { output: outputContent } : {}),
|
|
1574
|
+
},
|
|
1575
|
+
update: true, // Mark as update to existing message
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
else {
|
|
1579
|
+
// Tool call wasn't seen yet, send result only
|
|
1580
|
+
if (!seenPartIds.has(toolPartId)) {
|
|
1581
|
+
seenPartIds.add(toolPartId);
|
|
1582
|
+
partContentLengths.set(toolPartId, `success:${resultContent.length}`);
|
|
1583
|
+
parts.push({
|
|
1584
|
+
id: toolPartId,
|
|
1585
|
+
type: isPlanReview ? 'planreview' : coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1586
|
+
content: isPlanReview ? 'Plan mode' : `Tool result`,
|
|
1587
|
+
tool: {
|
|
1588
|
+
name: isPlanReview ? 'ExitPlanMode' : 'tool',
|
|
1589
|
+
status: 'success',
|
|
1590
|
+
output: outputContent,
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
else if (msgWithProps.type === 'tool_use_summary') {
|
|
1599
|
+
const partId = `tool-${msgWithProps.tool_use_id || Date.now()}`;
|
|
1600
|
+
const toolName = msgWithProps.tool_name || 'unknown';
|
|
1601
|
+
const status = msgWithProps.status || 'unknown';
|
|
1602
|
+
const isPlanReview = toolName === 'ExitPlanMode' || toolName === 'EnterPlanMode';
|
|
1603
|
+
const rawOutput = msgWithProps.output ? String(msgWithProps.output) : undefined;
|
|
1604
|
+
// Filter out confirmation prompts like "Exit/Enter plan mode?"
|
|
1605
|
+
const sanitizedOutput = isPlanReview && rawOutput && /^(exit|enter) plan mode\??$/i.test(rawOutput.trim())
|
|
1606
|
+
? undefined : rawOutput;
|
|
1607
|
+
const output = sanitizedOutput
|
|
1608
|
+
? (isPlanReview ? sanitizedOutput.slice(0, 50000) : sanitizedOutput.slice(0, 2000))
|
|
1609
|
+
: undefined;
|
|
1610
|
+
const partType = isPlanReview ? 'planreview' : coding_agent_adapter_1.MessagePartType.TOOL;
|
|
1611
|
+
const planLabel = toolName === 'EnterPlanMode' ? 'Enter plan mode' : 'Exit plan mode';
|
|
1612
|
+
if (seenPartIds.has(partId)) {
|
|
1613
|
+
// Tool_use was already emitted — send an UPDATE to merge the result into it
|
|
1614
|
+
partContentLengths.set(partId, `${status}:${output?.length || 0}`);
|
|
1615
|
+
parts.push({
|
|
1616
|
+
id: partId,
|
|
1617
|
+
type: partType,
|
|
1618
|
+
content: isPlanReview ? planLabel : `${toolName} — ${status}`,
|
|
1619
|
+
tool: { name: toolName, status, output },
|
|
1620
|
+
update: true,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
else {
|
|
1624
|
+
// First time seeing this tool — add as new entry
|
|
1625
|
+
seenPartIds.add(partId);
|
|
1626
|
+
partContentLengths.set(partId, `${status}:${output?.length || 0}`);
|
|
1627
|
+
parts.push({
|
|
1628
|
+
id: partId,
|
|
1629
|
+
type: partType,
|
|
1630
|
+
content: isPlanReview ? planLabel : `${toolName} — ${status}`,
|
|
1631
|
+
tool: { name: toolName, status, output },
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
else if (msgWithProps.type === 'tool_progress') {
|
|
1636
|
+
// SDKToolProgressMessage — periodic progress updates for running tools.
|
|
1637
|
+
// Update the existing tool part with elapsed time so the UI shows a timer.
|
|
1638
|
+
const toolUseId = msgWithProps.tool_use_id;
|
|
1639
|
+
if (toolUseId) {
|
|
1640
|
+
const partId = `tool-${toolUseId}`;
|
|
1641
|
+
const toolName = msgWithProps.tool_name || 'tool';
|
|
1642
|
+
const elapsed = msgWithProps.elapsed_time_seconds ?? 0;
|
|
1643
|
+
const elapsedLabel = elapsed >= 60
|
|
1644
|
+
? `${Math.floor(elapsed / 60)}m ${Math.round(elapsed % 60)}s`
|
|
1645
|
+
: `${Math.round(elapsed)}s`;
|
|
1646
|
+
if (seenPartIds.has(partId)) {
|
|
1647
|
+
// Tool was already emitted — send an update with elapsed time
|
|
1648
|
+
parts.push({
|
|
1649
|
+
id: partId,
|
|
1650
|
+
type: coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1651
|
+
tool: {
|
|
1652
|
+
name: toolName,
|
|
1653
|
+
status: 'running',
|
|
1654
|
+
title: `Running… ${elapsedLabel}`,
|
|
1655
|
+
},
|
|
1656
|
+
update: true,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
// If tool hasn't been seen yet, skip — progress before tool_use is meaningless
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
else if (msgWithProps.type === 'result') {
|
|
1663
|
+
// Surface error result messages (e.g., rate limit errors) to the UI
|
|
1664
|
+
const resultMsg = msg;
|
|
1665
|
+
if (resultMsg.is_error) {
|
|
1666
|
+
// Extract error text from all available fields (result, errors, error)
|
|
1667
|
+
const resultField = resultMsg.result;
|
|
1668
|
+
const errorsField = Array.isArray(resultMsg.errors) ? resultMsg.errors : [];
|
|
1669
|
+
const errorField = typeof resultMsg.error === 'string' ? resultMsg.error : '';
|
|
1670
|
+
let errorText;
|
|
1671
|
+
if (typeof resultField === 'string' && resultField.length > 0) {
|
|
1672
|
+
errorText = resultField;
|
|
1673
|
+
}
|
|
1674
|
+
else if (errorsField.length > 0) {
|
|
1675
|
+
errorText = errorsField.map(String).join('; ');
|
|
1676
|
+
}
|
|
1677
|
+
else if (errorField) {
|
|
1678
|
+
errorText = errorField;
|
|
1679
|
+
}
|
|
1680
|
+
else if (resultField && typeof resultField === 'object') {
|
|
1681
|
+
errorText = JSON.stringify(resultField);
|
|
1682
|
+
}
|
|
1683
|
+
else {
|
|
1684
|
+
errorText = 'An error occurred (no details available)';
|
|
1685
|
+
}
|
|
1686
|
+
const partId = `result-error-${msgWithProps.uuid || Date.now()}`;
|
|
1687
|
+
if (!seenPartIds.has(partId)) {
|
|
1688
|
+
seenPartIds.add(partId);
|
|
1689
|
+
partContentLengths.set(partId, String(errorText.length));
|
|
1690
|
+
parts.push({
|
|
1691
|
+
id: partId,
|
|
1692
|
+
type: coding_agent_adapter_1.MessagePartType.ERROR,
|
|
1693
|
+
text: errorText,
|
|
1694
|
+
role: 'system',
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
// Non-error result: extract the final assistant text as a safety net.
|
|
1700
|
+
// Normally the text was already sent in a preceding assistant message event,
|
|
1701
|
+
// but if the first streaming chunk had empty text and no subsequent chunk
|
|
1702
|
+
// updated it, the result message is the only source of the final response.
|
|
1703
|
+
const resultText = typeof resultMsg.result === 'string' ? resultMsg.result : '';
|
|
1704
|
+
if (resultText) {
|
|
1705
|
+
// Check if any assistant text part was already emitted with non-empty content.
|
|
1706
|
+
// If so, skip — the text is already shown. If not, emit it now.
|
|
1707
|
+
let hasNonEmptyText = false;
|
|
1708
|
+
for (const [pid, len] of partContentLengths) {
|
|
1709
|
+
if (pid.includes('-text-') && parseInt(len, 10) > 0) {
|
|
1710
|
+
hasNonEmptyText = true;
|
|
1711
|
+
break;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (!hasNonEmptyText) {
|
|
1715
|
+
const partId = `result-text-${msgWithProps.uuid || Date.now()}`;
|
|
1716
|
+
if (!seenPartIds.has(partId)) {
|
|
1717
|
+
seenPartIds.add(partId);
|
|
1718
|
+
partContentLengths.set(partId, String(resultText.length));
|
|
1719
|
+
parts.push({
|
|
1720
|
+
id: partId,
|
|
1721
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1722
|
+
text: resultText,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
else if (msgWithProps.type === 'system') {
|
|
1730
|
+
// Skip system init messages - they're internal session setup
|
|
1731
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.INIT) {
|
|
1732
|
+
return parts;
|
|
1733
|
+
}
|
|
1734
|
+
// SDKTaskStartedMessage — subagent task started
|
|
1735
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.TASK_STARTED) {
|
|
1736
|
+
const taskId = msgWithProps.task_id || msgWithProps.uuid || `task-${Date.now()}`;
|
|
1737
|
+
const partId = `task-${taskId}`;
|
|
1738
|
+
if (!seenPartIds.has(partId)) {
|
|
1739
|
+
seenPartIds.add(partId);
|
|
1740
|
+
partContentLengths.set(partId, `started:${taskId}`);
|
|
1741
|
+
parts.push({
|
|
1742
|
+
id: partId,
|
|
1743
|
+
type: coding_agent_adapter_1.MessagePartType.TASK_PROGRESS,
|
|
1744
|
+
content: msgWithProps.description || 'Subagent task started',
|
|
1745
|
+
taskProgress: {
|
|
1746
|
+
taskId,
|
|
1747
|
+
status: 'started',
|
|
1748
|
+
description: msgWithProps.description || '',
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
return parts;
|
|
1753
|
+
}
|
|
1754
|
+
// SDKTaskProgressMessage — periodic progress updates for running subagent tasks
|
|
1755
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.TASK_PROGRESS) {
|
|
1756
|
+
const taskId = msgWithProps.task_id || `task-${Date.now()}`;
|
|
1757
|
+
const partId = `task-${taskId}`;
|
|
1758
|
+
const usage = msgWithProps.usage;
|
|
1759
|
+
const alreadySeen = seenPartIds.has(partId);
|
|
1760
|
+
if (!alreadySeen) {
|
|
1761
|
+
// Missed task_started — create the entry
|
|
1762
|
+
seenPartIds.add(partId);
|
|
1763
|
+
}
|
|
1764
|
+
partContentLengths.set(partId, `running:${taskId}`);
|
|
1765
|
+
parts.push({
|
|
1766
|
+
id: partId,
|
|
1767
|
+
type: coding_agent_adapter_1.MessagePartType.TASK_PROGRESS,
|
|
1768
|
+
content: msgWithProps.description || 'Subagent task in progress',
|
|
1769
|
+
taskProgress: {
|
|
1770
|
+
taskId,
|
|
1771
|
+
status: 'running',
|
|
1772
|
+
description: msgWithProps.description || '',
|
|
1773
|
+
lastToolName: msgWithProps.last_tool_name,
|
|
1774
|
+
summary: msgWithProps.summary,
|
|
1775
|
+
usage,
|
|
1776
|
+
},
|
|
1777
|
+
update: alreadySeen,
|
|
1778
|
+
});
|
|
1779
|
+
return parts;
|
|
1780
|
+
}
|
|
1781
|
+
// SDKTaskNotificationMessage — subtask completion notifications
|
|
1782
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.TASK_NOTIFICATION) {
|
|
1783
|
+
const taskId = msgWithProps.task_id || `task-${Date.now()}`;
|
|
1784
|
+
const partId = `task-${taskId}`;
|
|
1785
|
+
const taskStatus = (msgWithProps.status || 'completed');
|
|
1786
|
+
const summary = msgWithProps.summary || `Task ${taskStatus}`;
|
|
1787
|
+
const usage = msgWithProps.usage;
|
|
1788
|
+
const alreadySeen = seenPartIds.has(partId);
|
|
1789
|
+
if (!alreadySeen) {
|
|
1790
|
+
seenPartIds.add(partId);
|
|
1791
|
+
}
|
|
1792
|
+
partContentLengths.set(partId, `${taskStatus}:${taskId}`);
|
|
1793
|
+
parts.push({
|
|
1794
|
+
id: partId,
|
|
1795
|
+
type: coding_agent_adapter_1.MessagePartType.TASK_PROGRESS,
|
|
1796
|
+
content: summary,
|
|
1797
|
+
taskProgress: {
|
|
1798
|
+
taskId,
|
|
1799
|
+
status: taskStatus,
|
|
1800
|
+
description: summary,
|
|
1801
|
+
summary,
|
|
1802
|
+
usage,
|
|
1803
|
+
},
|
|
1804
|
+
update: alreadySeen,
|
|
1805
|
+
});
|
|
1806
|
+
return parts;
|
|
1807
|
+
}
|
|
1808
|
+
// ── Internal background-task bookkeeping — never rendered ──
|
|
1809
|
+
// `task_updated` carries wire-level status patches and `background_tasks_changed`
|
|
1810
|
+
// carries the in-flight list. Both are consumed by trackBackgroundTask(); the
|
|
1811
|
+
// user-visible progress is already covered by task_started / task_progress /
|
|
1812
|
+
// task_notification. Without this they fall through to the generic system
|
|
1813
|
+
// handler below, which pushes the raw subtype string as a text bubble —
|
|
1814
|
+
// spamming the transcript with literal "task_updated" /
|
|
1815
|
+
// "background_tasks_changed" messages between every subagent update.
|
|
1816
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.TASK_UPDATED ||
|
|
1817
|
+
msgWithProps.subtype === ClaudeSystemSubtype.BACKGROUND_TASKS_CHANGED) {
|
|
1818
|
+
return parts;
|
|
1819
|
+
}
|
|
1820
|
+
// SDKStatusMessage — transient status indicators (e.g. 'compacting')
|
|
1821
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.STATUS) {
|
|
1822
|
+
// null status means "cleared" — skip
|
|
1823
|
+
if (!msgWithProps.status)
|
|
1824
|
+
return parts;
|
|
1825
|
+
const partId = `system-status-${msgWithProps.uuid || Date.now()}`;
|
|
1826
|
+
if (!seenPartIds.has(partId)) {
|
|
1827
|
+
seenPartIds.add(partId);
|
|
1828
|
+
const statusLabel = msgWithProps.status === 'compacting'
|
|
1829
|
+
? 'Compacting conversation history…'
|
|
1830
|
+
: String(msgWithProps.status);
|
|
1831
|
+
partContentLengths.set(partId, String(statusLabel.length));
|
|
1832
|
+
parts.push({
|
|
1833
|
+
id: partId,
|
|
1834
|
+
type: 'system-status',
|
|
1835
|
+
content: statusLabel,
|
|
1836
|
+
role: 'system',
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
return parts;
|
|
1840
|
+
}
|
|
1841
|
+
// SDKThinkingTokensMessage — reasoning/thinking token updates from Claude Code
|
|
1842
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.THINKING_TOKENS) {
|
|
1843
|
+
const partId = `thinking-tokens-${msgWithProps.session_id || 'session'}`;
|
|
1844
|
+
const alreadySeen = seenPartIds.has(partId);
|
|
1845
|
+
const estimatedTokens = typeof msgWithProps.estimated_tokens === 'number'
|
|
1846
|
+
? msgWithProps.estimated_tokens
|
|
1847
|
+
: undefined;
|
|
1848
|
+
const content = estimatedTokens !== undefined
|
|
1849
|
+
? `Estimated thinking tokens: ${estimatedTokens}`
|
|
1850
|
+
: 'Estimating thinking tokens...';
|
|
1851
|
+
if (!alreadySeen) {
|
|
1852
|
+
seenPartIds.add(partId);
|
|
1853
|
+
}
|
|
1854
|
+
partContentLengths.set(partId, String(content.length));
|
|
1855
|
+
parts.push({
|
|
1856
|
+
id: partId,
|
|
1857
|
+
type: coding_agent_adapter_1.MessagePartType.REASONING,
|
|
1858
|
+
text: content,
|
|
1859
|
+
role: 'assistant',
|
|
1860
|
+
update: alreadySeen,
|
|
1861
|
+
});
|
|
1862
|
+
return parts;
|
|
1863
|
+
}
|
|
1864
|
+
const partId = `system-${msgWithProps.uuid || Date.now()}`;
|
|
1865
|
+
if (!seenPartIds.has(partId)) {
|
|
1866
|
+
seenPartIds.add(partId);
|
|
1867
|
+
const content = msgWithProps.subtype || 'System message';
|
|
1868
|
+
partContentLengths.set(partId, String(content.length));
|
|
1869
|
+
parts.push({
|
|
1870
|
+
id: partId,
|
|
1871
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1872
|
+
content,
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return parts;
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Builds a human-readable title for a tool call from its input.
|
|
1880
|
+
* Mirrors the titles that OpenCode/buildPartPayload produces.
|
|
1881
|
+
*/
|
|
1882
|
+
buildToolTitle(toolName, input) {
|
|
1883
|
+
if (!input)
|
|
1884
|
+
return '';
|
|
1885
|
+
switch (toolName) {
|
|
1886
|
+
case 'Bash':
|
|
1887
|
+
return input.command ? String(input.command) : (input.description ? String(input.description) : '');
|
|
1888
|
+
case 'Read':
|
|
1889
|
+
return input.file_path ? String(input.file_path) : '';
|
|
1890
|
+
case 'Edit':
|
|
1891
|
+
case 'Write':
|
|
1892
|
+
return input.file_path ? String(input.file_path) : '';
|
|
1893
|
+
case 'Grep':
|
|
1894
|
+
return input.pattern
|
|
1895
|
+
? `${input.pattern}${input.path ? ` in ${input.path}` : ''}`
|
|
1896
|
+
: '';
|
|
1897
|
+
case 'Glob':
|
|
1898
|
+
return input.pattern ? String(input.pattern) : '';
|
|
1899
|
+
case 'Task':
|
|
1900
|
+
return input.description ? String(input.description) : '';
|
|
1901
|
+
case 'WebFetch':
|
|
1902
|
+
return input.url ? String(input.url) : '';
|
|
1903
|
+
case 'WebSearch':
|
|
1904
|
+
return input.query ? String(input.query) : '';
|
|
1905
|
+
case 'TodoWrite':
|
|
1906
|
+
return 'Todo List';
|
|
1907
|
+
case 'AskUserQuestion':
|
|
1908
|
+
return 'Question';
|
|
1909
|
+
case 'EnterPlanMode':
|
|
1910
|
+
return 'Enter plan mode';
|
|
1911
|
+
case 'ExitPlanMode':
|
|
1912
|
+
return 'Exit plan mode';
|
|
1913
|
+
default:
|
|
1914
|
+
return '';
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
exports.ClaudeCodeAdapter = ClaudeCodeAdapter;
|