@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1

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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. 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;