claude-code-rust 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,20 +2,82 @@ import { randomUUID } from "node:crypto";
2
2
  import { spawn as spawnChild } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import { query, } from "@anthropic-ai/claude-agent-sdk";
5
- import { AsyncQueue, logPermissionDebug } from "./shared.js";
6
- import { formatPermissionUpdates, permissionOptionsFromSuggestions, permissionResultFromOutcome, } from "./permissions.js";
7
- import { writeEvent, failConnection, emitSessionUpdate, emitConnectEvent, } from "./events.js";
5
+ import { bridgeLogger, LOG_TARGETS, logSdkStderrLine } from "./logger.js";
6
+ import { AsyncQueue } from "./shared.js";
7
+ import { permissionOptionsFromSuggestions, permissionResultFromOutcome, } from "./permissions.js";
8
+ import { failConnection, emitSessionUpdate, emitConnectEvent, emitPermissionRequestEvent, emitElicitationRequestEvent, } from "./events.js";
8
9
  import { ensureToolCallVisible, setToolCallStatus, } from "./tool_calls.js";
9
10
  import { requestExitPlanModeApproval, requestAskUserQuestionAnswers, EXIT_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "./user_interaction.js";
10
11
  import { mapAvailableAgents, emitAvailableAgentsIfChanged, refreshAvailableAgents } from "./agents.js";
11
12
  import { emitAuthRequired, emitFastModeUpdateIfChanged } from "./error_classification.js";
13
+ function permissionDisplayFromCanUseOptions(options) {
14
+ const title = typeof options.title === "string" ? options.title.trim() : "";
15
+ const displayName = typeof options.displayName === "string" ? options.displayName.trim() : "";
16
+ const description = typeof options.description === "string" ? options.description.trim() : "";
17
+ if (!title && !displayName && !description) {
18
+ return undefined;
19
+ }
20
+ return {
21
+ ...(title ? { title } : {}),
22
+ ...(displayName ? { display_name: displayName } : {}),
23
+ ...(description ? { description } : {}),
24
+ };
25
+ }
26
+ const requestUserDialogInterceptorInstalled = Symbol("requestUserDialogInterceptorInstalled");
12
27
  export const sessions = new Map();
28
+ function nonEmptyString(value) {
29
+ return typeof value === "string" && value.trim().length > 0;
30
+ }
31
+ export function shouldEmitStartupAuthRequiredForAccount(account) {
32
+ const provider = account.apiProvider;
33
+ if (nonEmptyString(provider) && provider !== "firstParty") {
34
+ return false;
35
+ }
36
+ return !nonEmptyString(account.email) && !nonEmptyString(account.apiKeySource);
37
+ }
13
38
  const DEFAULT_SETTING_SOURCES = ["user", "project", "local"];
14
39
  const DEFAULT_MODEL_NAME = "default";
15
40
  const DEFAULT_PERMISSION_MODE = "default";
41
+ function isSdkElicitationContentValue(value) {
42
+ return (typeof value === "string" ||
43
+ typeof value === "number" ||
44
+ typeof value === "boolean" ||
45
+ (Array.isArray(value) && value.every((entry) => typeof entry === "string")));
46
+ }
47
+ function normalizeSdkElicitationContent(content) {
48
+ if (!content) {
49
+ return undefined;
50
+ }
51
+ const normalized = {};
52
+ for (const [key, value] of Object.entries(content)) {
53
+ if (isSdkElicitationContentValue(value)) {
54
+ normalized[key] = value;
55
+ }
56
+ }
57
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
58
+ }
16
59
  function settingsObjectFromLaunchSettings(launchSettings) {
17
60
  return launchSettings.settings;
18
61
  }
62
+ function normalizedSettingsFromLaunchSettings(launchSettings) {
63
+ const settings = settingsObjectFromLaunchSettings(launchSettings);
64
+ if (!settings) {
65
+ return undefined;
66
+ }
67
+ const sandbox = settings.sandbox && typeof settings.sandbox === "object" && !Array.isArray(settings.sandbox)
68
+ ? settings.sandbox
69
+ : undefined;
70
+ if (sandbox?.enabled === true && sandbox.failIfUnavailable === undefined) {
71
+ return {
72
+ ...settings,
73
+ sandbox: {
74
+ ...sandbox,
75
+ failIfUnavailable: false,
76
+ },
77
+ };
78
+ }
79
+ return settings;
80
+ }
19
81
  export function sessionById(sessionId) {
20
82
  return sessions.get(sessionId) ?? null;
21
83
  }
@@ -27,6 +89,77 @@ export function updateSessionId(session, newSessionId) {
27
89
  session.sessionId = newSessionId;
28
90
  sessions.set(newSessionId, session);
29
91
  }
92
+ function isRequestUserDialogControlRequest(value) {
93
+ if (!value || typeof value !== "object") {
94
+ return false;
95
+ }
96
+ const record = value;
97
+ const request = record.request;
98
+ if (!request || typeof request !== "object") {
99
+ return false;
100
+ }
101
+ const inner = request;
102
+ const payload = inner.payload;
103
+ return (typeof record.request_id === "string" &&
104
+ inner.subtype === "request_user_dialog" &&
105
+ typeof inner.dialog_kind === "string" &&
106
+ Boolean(payload && typeof payload === "object" && !Array.isArray(payload)));
107
+ }
108
+ export function attachRequestUserDialogInterceptor(query, sessionIdForLogs) {
109
+ const internalQuery = query;
110
+ if (internalQuery[requestUserDialogInterceptorInstalled]) {
111
+ return true;
112
+ }
113
+ if (typeof internalQuery.processControlRequest !== "function") {
114
+ bridgeLogger.warn({
115
+ target: LOG_TARGETS.APP_SESSION,
116
+ eventName: "request_user_dialog_interceptor_unavailable",
117
+ message: "request_user_dialog interceptor could not be installed",
118
+ outcome: "failure",
119
+ sessionId: sessionIdForLogs(),
120
+ });
121
+ return false;
122
+ }
123
+ const originalProcessControlRequest = internalQuery.processControlRequest.bind(query);
124
+ internalQuery.processControlRequest = async (request, signal) => {
125
+ // SDK 0.2.104 also added cancel_async_message and seed_read_state control
126
+ // requests. Keep those delegated to the SDK internals: claude-rs does not
127
+ // own the SDK async-message queue or read-state cache, so TUI-level commands
128
+ // for them would add unsupported host behavior without user-visible value.
129
+ if (isRequestUserDialogControlRequest(request)) {
130
+ bridgeLogger.warn({
131
+ target: LOG_TARGETS.APP_SESSION,
132
+ eventName: "request_user_dialog_received",
133
+ message: "request_user_dialog control request received",
134
+ outcome: "failure",
135
+ sessionId: sessionIdForLogs(),
136
+ requestId: request.request_id,
137
+ ...(typeof request.request.tool_use_id === "string"
138
+ ? { toolCallId: request.request.tool_use_id }
139
+ : {}),
140
+ fields: {
141
+ dialog_kind: request.request.dialog_kind,
142
+ raw_payload: request.request.payload,
143
+ raw_request: request.request,
144
+ },
145
+ });
146
+ // TODO(request_user_dialog): Revisit this when a real claude-rs host flow needs it.
147
+ // For now we only log the full control request and reject it explicitly because
148
+ // normal TUI sessions do not appear to exercise these dialog kinds.
149
+ throw new Error(`request_user_dialog is not supported by claude-rs yet (dialog_kind: ${request.request.dialog_kind})`);
150
+ }
151
+ return await originalProcessControlRequest(request, signal);
152
+ };
153
+ internalQuery[requestUserDialogInterceptorInstalled] = true;
154
+ bridgeLogger.info({
155
+ target: LOG_TARGETS.APP_SESSION,
156
+ eventName: "request_user_dialog_interceptor_installed",
157
+ message: "request_user_dialog interceptor installed",
158
+ outcome: "success",
159
+ sessionId: sessionIdForLogs(),
160
+ });
161
+ return true;
162
+ }
30
163
  export async function closeSession(session) {
31
164
  session.input.close();
32
165
  session.query.close();
@@ -44,34 +177,77 @@ export async function closeSession(session) {
44
177
  }
45
178
  session.pendingElicitations.clear();
46
179
  }
47
- export async function closeAllSessions() {
180
+ export async function closeSessionWithLogging(session, options = {}) {
181
+ await closeSession(session);
182
+ bridgeLogger.info({
183
+ target: LOG_TARGETS.APP_SESSION,
184
+ eventName: "session_closed",
185
+ message: "session closed",
186
+ outcome: "success",
187
+ sessionId: session.sessionId,
188
+ ...(options.requestId ? { requestId: options.requestId } : {}),
189
+ fields: { reason: options.reason ?? "unspecified" },
190
+ });
191
+ }
192
+ export async function closeAllSessions(options = {}) {
48
193
  const active = Array.from(sessions.values());
49
194
  sessions.clear();
50
- await Promise.all(active.map((session) => closeSession(session)));
195
+ await Promise.all(active.map((session) => closeSessionWithLogging(session, {
196
+ reason: options.reason ?? "bulk_close",
197
+ requestId: options.requestId,
198
+ })));
199
+ bridgeLogger.info({
200
+ target: LOG_TARGETS.APP_SESSION,
201
+ eventName: "all_sessions_closed",
202
+ message: "all sessions closed",
203
+ outcome: "success",
204
+ ...(options.requestId ? { requestId: options.requestId } : {}),
205
+ count: active.length,
206
+ fields: { reason: options.reason ?? "bulk_close" },
207
+ });
51
208
  }
52
209
  export async function createSession(params) {
53
210
  const input = new AsyncQueue();
54
211
  const provisionalSessionId = params.resume ?? randomUUID();
55
212
  const initialModel = initialSessionModel(params.launchSettings);
56
213
  const initialMode = initialSessionMode(params.launchSettings);
214
+ const supportsBypassPermissionsMode = startupPermissionModeOptions(params.launchSettings).allowDangerouslySkipPermissions === true;
215
+ const historyUpdateCount = params.resumeUpdates?.length ?? 0;
216
+ const staleSessionCount = params.sessionsToCloseAfterConnect?.length ?? 0;
57
217
  let session;
218
+ const sessionIdForLogs = () => session?.sessionId ?? provisionalSessionId;
58
219
  const canUseTool = async (toolName, inputData, options) => {
59
220
  const toolUseId = options.toolUseID;
60
221
  if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
61
222
  const existing = ensureToolCallVisible(session, toolUseId, toolName, inputData);
62
223
  return await requestExitPlanModeApproval(session, toolUseId, inputData, existing);
63
224
  }
64
- logPermissionDebug(`request tool_use_id=${toolUseId} tool=${toolName} blocked_path=${options.blockedPath ?? "<none>"} ` +
65
- `decision_reason=${options.decisionReason ?? "<none>"} suggestions=${formatPermissionUpdates(options.suggestions)}`);
66
225
  const existing = ensureToolCallVisible(session, toolUseId, toolName, inputData);
67
226
  if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
68
227
  return await requestAskUserQuestionAnswers(session, toolUseId, inputData, existing);
69
228
  }
229
+ const display = permissionDisplayFromCanUseOptions(options);
70
230
  const request = {
71
231
  tool_call: existing,
72
232
  options: permissionOptionsFromSuggestions(options.suggestions),
233
+ ...(display ? { display } : {}),
73
234
  };
74
- writeEvent({ event: "permission_request", session_id: session.sessionId, request });
235
+ bridgeLogger.info({
236
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
237
+ eventName: "permission_request_created",
238
+ message: "permission request created",
239
+ outcome: "start",
240
+ sessionId: session.sessionId,
241
+ toolCallId: toolUseId,
242
+ count: request.options.length,
243
+ fields: {
244
+ tool_name: toolName,
245
+ agent_id: options.agentID,
246
+ blocked_path: options.blockedPath ?? "<none>",
247
+ decision_reason: options.decisionReason ?? "<none>",
248
+ },
249
+ });
250
+ emitPermissionRequestEvent(session.sessionId, request);
75
251
  return await new Promise((resolve) => {
76
252
  session.pendingPermissions.set(toolUseId, {
77
253
  resolve,
@@ -89,6 +265,21 @@ export async function createSession(params) {
89
265
  throw new Error(`CLAUDE_CODE_EXECUTABLE does not exist: ${claudeCodeExecutable}`);
90
266
  }
91
267
  let queryHandle;
268
+ bridgeLogger.info({
269
+ target: LOG_TARGETS.APP_SESSION,
270
+ eventName: "session_create_started",
271
+ message: "session creation started",
272
+ outcome: "start",
273
+ ...(params.requestId ? { requestId: params.requestId } : {}),
274
+ sessionId: provisionalSessionId,
275
+ fields: {
276
+ cwd: params.cwd,
277
+ connect_event: params.connectEvent,
278
+ resume_requested: params.resume !== undefined,
279
+ history_update_count: historyUpdateCount,
280
+ stale_session_count: staleSessionCount,
281
+ },
282
+ });
92
283
  try {
93
284
  queryHandle = query({
94
285
  prompt: input,
@@ -103,12 +294,26 @@ export async function createSession(params) {
103
294
  sdkDebugFile,
104
295
  enableSdkDebug,
105
296
  enableSpawnDebug,
106
- sessionIdForLogs: () => session.sessionId,
297
+ sessionIdForLogs,
107
298
  }),
108
299
  });
300
+ attachRequestUserDialogInterceptor(queryHandle, sessionIdForLogs);
109
301
  }
110
302
  catch (error) {
111
303
  const message = error instanceof Error ? error.message : String(error);
304
+ bridgeLogger.error({
305
+ target: LOG_TARGETS.APP_SESSION,
306
+ eventName: "session_query_failed",
307
+ message: "session query creation failed",
308
+ outcome: "failure",
309
+ ...(params.requestId ? { requestId: params.requestId } : {}),
310
+ sessionId: provisionalSessionId,
311
+ fields: {
312
+ cwd: params.cwd,
313
+ resume_requested: params.resume !== undefined,
314
+ error_message: message,
315
+ },
316
+ });
112
317
  throw new Error(`query() failed: node_executable=${process.execPath}; cwd=${params.cwd}; ` +
113
318
  `resume=${params.resume ?? "<none>"}; ` +
114
319
  `CLAUDE_CODE_EXECUTABLE=${claudeCodeExecutable ?? "<unset>"}; error=${message}`);
@@ -117,8 +322,12 @@ export async function createSession(params) {
117
322
  sessionId: provisionalSessionId,
118
323
  cwd: params.cwd,
119
324
  model: initialModel,
325
+ ...(initialModel ? { requestedModelId: initialModel } : {}),
120
326
  availableModels: [],
121
327
  mode: initialMode,
328
+ supportedModeIds: [],
329
+ runtimeUnavailableModeIds: [],
330
+ supportsBypassPermissionsMode,
122
331
  fastModeState: "off",
123
332
  query: queryHandle,
124
333
  input,
@@ -139,23 +348,76 @@ export async function createSession(params) {
139
348
  ? { sessionsToCloseAfterConnect: params.sessionsToCloseAfterConnect }
140
349
  : {}),
141
350
  };
351
+ refreshCurrentModel(session);
352
+ const { refreshSupportedModesForSession } = await import("./commands.js");
353
+ refreshSupportedModesForSession(session);
142
354
  sessions.set(provisionalSessionId, session);
355
+ bridgeLogger.info({
356
+ target: LOG_TARGETS.APP_SESSION,
357
+ eventName: "session_query_started",
358
+ message: "session query started",
359
+ outcome: "success",
360
+ ...(params.requestId ? { requestId: params.requestId } : {}),
361
+ sessionId: session.sessionId,
362
+ fields: {
363
+ cwd: session.cwd,
364
+ connect_event: session.connectEvent,
365
+ resume_requested: params.resume !== undefined,
366
+ },
367
+ });
368
+ bridgeLogger.info({
369
+ target: LOG_TARGETS.APP_SESSION,
370
+ eventName: "session_create_registered",
371
+ message: "session registered in bridge state",
372
+ outcome: "success",
373
+ ...(params.requestId ? { requestId: params.requestId } : {}),
374
+ sessionId: session.sessionId,
375
+ count: sessions.size,
376
+ fields: {
377
+ active_session_count: sessions.size,
378
+ connect_event: session.connectEvent,
379
+ },
380
+ });
143
381
  // In stream-input mode the SDK may defer init until input arrives.
144
382
  // Trigger initialization explicitly so the Rust UI can receive `connected`
145
383
  // before the first user prompt.
146
384
  void session.query
147
385
  .initializationResult()
148
- .then((result) => {
386
+ .then(async (result) => {
387
+ bridgeLogger.info({
388
+ target: LOG_TARGETS.APP_SESSION,
389
+ eventName: "session_initialization_completed",
390
+ message: "session initialization completed",
391
+ outcome: "success",
392
+ ...(session.connectRequestId ? { requestId: session.connectRequestId } : {}),
393
+ sessionId: session.sessionId,
394
+ fields: {
395
+ available_model_count: Array.isArray(result.models) ? result.models.length : 0,
396
+ connect_event: session.connectEvent,
397
+ history_update_count: session.resumeUpdates?.length ?? 0,
398
+ },
399
+ });
149
400
  session.availableModels = mapAvailableModels(result.models);
401
+ const currentModelChanged = refreshCurrentModel(session);
402
+ const { buildModeState, refreshSupportedModesForSession } = await import("./commands.js");
403
+ refreshSupportedModesForSession(session);
150
404
  if (!session.connected) {
151
405
  emitConnectEvent(session);
152
406
  }
407
+ else {
408
+ if (currentModelChanged) {
409
+ emitCurrentModelUpdate(session);
410
+ }
411
+ if (session.mode) {
412
+ emitSessionUpdate(session.sessionId, {
413
+ type: "mode_state_update",
414
+ mode: buildModeState(session, session.mode),
415
+ });
416
+ }
417
+ }
153
418
  // Proactively detect missing auth from account info so the UI can
154
419
  // show the login hint immediately, without waiting for the first prompt.
155
- const acct = result.account;
156
- const hasCredentials = (typeof acct.email === "string" && acct.email.trim().length > 0) ||
157
- (typeof acct.apiKeySource === "string" && acct.apiKeySource.trim().length > 0);
158
- if (!hasCredentials) {
420
+ if (shouldEmitStartupAuthRequiredForAccount(result.account)) {
159
421
  emitAuthRequired(session);
160
422
  }
161
423
  emitFastModeUpdateIfChanged(session, result.fast_mode_state);
@@ -177,6 +439,15 @@ export async function createSession(params) {
177
439
  return;
178
440
  }
179
441
  const message = error instanceof Error ? error.message : String(error);
442
+ bridgeLogger.error({
443
+ target: LOG_TARGETS.APP_SESSION,
444
+ eventName: "session_initialization_failed",
445
+ message: "session initialization failed before connect",
446
+ outcome: "failure",
447
+ ...(session.connectRequestId ? { requestId: session.connectRequestId } : {}),
448
+ sessionId: session.sessionId,
449
+ fields: { error_message: message },
450
+ });
180
451
  failConnection(`agent initialization failed: ${message}`, session.connectRequestId);
181
452
  session.connectRequestId = undefined;
182
453
  });
@@ -188,21 +459,81 @@ export async function createSession(params) {
188
459
  handleSdkMessage(session, message);
189
460
  }
190
461
  if (!session.connected) {
462
+ bridgeLogger.error({
463
+ target: LOG_TARGETS.APP_SESSION,
464
+ eventName: "session_stream_ended_before_connect",
465
+ message: "session stream ended before connect",
466
+ outcome: "failure",
467
+ ...(params.requestId ? { requestId: params.requestId } : {}),
468
+ sessionId: session.sessionId,
469
+ });
191
470
  failConnection("agent stream ended before session initialization", params.requestId);
192
471
  }
193
472
  }
194
473
  catch (error) {
195
474
  const message = error instanceof Error ? error.message : String(error);
475
+ bridgeLogger.error({
476
+ target: LOG_TARGETS.APP_SESSION,
477
+ eventName: "session_stream_failed_before_connect",
478
+ message: "session stream failed before connect",
479
+ outcome: "failure",
480
+ ...(params.requestId ? { requestId: params.requestId } : {}),
481
+ sessionId: session.sessionId,
482
+ fields: { error_message: message },
483
+ });
196
484
  failConnection(`agent stream failed: ${message}`, params.requestId);
197
485
  }
198
486
  })();
199
487
  }
488
+ function logSdkProcessSpawnStarted(options, includeArgsPreview) {
489
+ bridgeLogger.info({
490
+ target: LOG_TARGETS.BRIDGE_SDK,
491
+ eventName: "sdk_spawn_started",
492
+ message: "spawning Claude Code process",
493
+ outcome: "start",
494
+ fields: {
495
+ command: options.command,
496
+ cwd: options.cwd ?? "<none>",
497
+ arg_count: options.args.length,
498
+ ...(includeArgsPreview ? { args_preview: options.args.slice(0, 5) } : {}),
499
+ },
500
+ });
501
+ }
502
+ function logSdkProcessSpawned(sessionId, child, cwd) {
503
+ bridgeLogger.info({
504
+ target: LOG_TARGETS.BRIDGE_SDK,
505
+ eventName: "sdk_spawned",
506
+ message: "Claude Code process spawned",
507
+ outcome: "success",
508
+ ...(sessionId ? { sessionId } : {}),
509
+ fields: {
510
+ cwd: cwd ?? "<none>",
511
+ pid: child.pid ?? "<none>",
512
+ },
513
+ });
514
+ }
515
+ function logSdkProcessExit(sessionId, code, signal) {
516
+ const exitedCleanly = code === 0 && signal === null;
517
+ const logger = exitedCleanly ? bridgeLogger.info : bridgeLogger.warn;
518
+ logger({
519
+ target: LOG_TARGETS.BRIDGE_SDK,
520
+ eventName: "sdk_process_exited",
521
+ message: "Claude Code process exited",
522
+ outcome: exitedCleanly ? "success" : "failure",
523
+ ...(sessionId ? { sessionId } : {}),
524
+ fields: {
525
+ exit_code: code ?? "<none>",
526
+ exit_signal: signal ?? "<none>",
527
+ },
528
+ });
529
+ }
200
530
  function permissionModeFromSettingsValue(rawMode) {
201
531
  if (typeof rawMode !== "string") {
202
532
  return undefined;
203
533
  }
204
534
  switch (rawMode) {
205
535
  case "default":
536
+ case "auto":
206
537
  case "acceptEdits":
207
538
  case "bypassPermissions":
208
539
  case "plan":
@@ -261,12 +592,14 @@ export function buildQueryOptions(params) {
261
592
  const systemPrompt = systemPromptFromLaunchSettings(params.launchSettings);
262
593
  const modelOption = startupModelOption(params.launchSettings);
263
594
  const permissionModeOptions = startupPermissionModeOptions(params.launchSettings);
595
+ const settings = normalizedSettingsFromLaunchSettings(params.launchSettings);
264
596
  return {
265
597
  cwd: params.cwd,
266
598
  includePartialMessages: true,
599
+ promptSuggestions: true,
267
600
  executable: "node",
268
601
  ...(params.resume ? {} : { sessionId: params.provisionalSessionId }),
269
- ...(params.launchSettings.settings ? { settings: params.launchSettings.settings } : {}),
602
+ ...(settings ? { settings } : {}),
270
603
  ...modelOption,
271
604
  ...permissionModeOptions,
272
605
  toolConfig: { askUserQuestion: { previewFormat: "markdown" } },
@@ -281,28 +614,37 @@ export function buildQueryOptions(params) {
281
614
  ...(params.sdkDebugFile ? { debugFile: params.sdkDebugFile } : {}),
282
615
  stderr: (line) => {
283
616
  if (line.trim().length > 0) {
284
- console.error(`[sdk stderr] ${line}`);
617
+ logSdkStderrLine(line);
285
618
  }
286
619
  },
287
- ...(params.enableSpawnDebug
288
- ? {
289
- spawnClaudeCodeProcess: (options) => {
290
- console.error(`[sdk spawn] command=${options.command} args=${JSON.stringify(options.args)} cwd=${options.cwd ?? "<none>"}`);
291
- const child = spawnChild(options.command, options.args, {
292
- cwd: options.cwd,
293
- env: options.env,
294
- signal: options.signal,
295
- stdio: ["pipe", "pipe", "pipe"],
296
- windowsHide: true,
297
- });
298
- child.on("error", (error) => {
299
- console.error(`[sdk spawn error] code=${error.code ?? "<none>"} message=${error.message}`);
300
- });
301
- return child;
302
- },
303
- }
304
- : {}),
305
- // Match claude-agent-acp defaults to avoid emitting an empty
620
+ spawnClaudeCodeProcess: (options) => {
621
+ logSdkProcessSpawnStarted(options, params.enableSpawnDebug);
622
+ const child = spawnChild(options.command, options.args, {
623
+ cwd: options.cwd,
624
+ env: options.env,
625
+ signal: options.signal,
626
+ stdio: ["pipe", "pipe", "pipe"],
627
+ windowsHide: true,
628
+ });
629
+ logSdkProcessSpawned(params.sessionIdForLogs() || undefined, child, options.cwd);
630
+ child.on("error", (error) => {
631
+ const sessionId = params.sessionIdForLogs();
632
+ bridgeLogger.error({
633
+ target: LOG_TARGETS.BRIDGE_SDK,
634
+ eventName: "sdk_spawn_failed",
635
+ message: "Claude Code process spawn failed",
636
+ outcome: "failure",
637
+ ...(sessionId ? { sessionId } : {}),
638
+ errorCode: error.code ?? "<none>",
639
+ fields: { error_message: error.message },
640
+ });
641
+ });
642
+ child.on("exit", (code, signal) => {
643
+ logSdkProcessExit(params.sessionIdForLogs() || undefined, code, signal);
644
+ });
645
+ return child;
646
+ },
647
+ // Match the Claude Code CLI defaults to avoid emitting an empty
306
648
  // --setting-sources argument.
307
649
  settingSources: DEFAULT_SETTING_SOURCES,
308
650
  resume: params.resume,
@@ -333,14 +675,32 @@ export function buildQueryOptions(params) {
333
675
  ? { requested_schema: request.requestedSchema }
334
676
  : {}),
335
677
  };
336
- writeEvent({
337
- event: "elicitation_request",
338
- session_id: params.sessionIdForLogs(),
339
- request: normalized,
678
+ bridgeLogger.info({
679
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
680
+ eventName: "elicitation_request_created",
681
+ message: "elicitation request created",
682
+ outcome: "start",
683
+ sessionId: params.sessionIdForLogs(),
684
+ requestId,
685
+ fields: {
686
+ server_name: normalized.server_name,
687
+ mode: normalized.mode,
688
+ has_url: normalized.url !== undefined,
689
+ },
340
690
  });
691
+ emitElicitationRequestEvent(params.sessionIdForLogs(), normalized);
341
692
  return await new Promise((resolve) => {
342
693
  const currentSession = sessions.get(params.sessionIdForLogs());
343
694
  if (!currentSession) {
695
+ bridgeLogger.warn({
696
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
697
+ eventName: "elicitation_request_dropped",
698
+ message: "elicitation request dropped without an active session",
699
+ outcome: "dropped",
700
+ sessionId: params.sessionIdForLogs(),
701
+ requestId,
702
+ fields: { reason: "unknown_session" },
703
+ });
344
704
  resolve({ action: "cancel" });
345
705
  return;
346
706
  }
@@ -386,29 +746,76 @@ export function mapAvailableModels(models) {
386
746
  }));
387
747
  }
388
748
  export function handlePermissionResponse(command) {
749
+ bridgeLogger.info({
750
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
751
+ eventName: "permission_response_received",
752
+ message: "permission response received",
753
+ outcome: "success",
754
+ sessionId: command.session_id,
755
+ toolCallId: command.tool_call_id,
756
+ fields: {
757
+ response_kind: command.outcome.outcome,
758
+ selected_option: command.outcome.outcome === "selected" ? command.outcome.option_id : "cancelled",
759
+ },
760
+ });
389
761
  const session = sessionById(command.session_id);
390
762
  if (!session) {
391
- logPermissionDebug(`response dropped: unknown session session_id=${command.session_id} tool_call_id=${command.tool_call_id}`);
763
+ bridgeLogger.warn({
764
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
765
+ eventName: "permission_response_dropped",
766
+ message: "permission response dropped for unknown session",
767
+ outcome: "dropped",
768
+ sessionId: command.session_id,
769
+ toolCallId: command.tool_call_id,
770
+ fields: { reason: "unknown_session" },
771
+ });
392
772
  return;
393
773
  }
394
774
  const resolver = session.pendingPermissions.get(command.tool_call_id);
395
775
  if (!resolver) {
396
- logPermissionDebug(`response dropped: no pending resolver session_id=${command.session_id} tool_call_id=${command.tool_call_id}`);
776
+ bridgeLogger.warn({
777
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
778
+ eventName: "permission_response_dropped",
779
+ message: "permission response dropped without a pending resolver",
780
+ outcome: "dropped",
781
+ sessionId: command.session_id,
782
+ toolCallId: command.tool_call_id,
783
+ fields: { reason: "missing_pending_resolver" },
784
+ });
397
785
  return;
398
786
  }
399
787
  session.pendingPermissions.delete(command.tool_call_id);
400
788
  const outcome = command.outcome;
401
789
  if (resolver.onOutcome) {
790
+ bridgeLogger.info({
791
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
792
+ eventName: "permission_response_applied",
793
+ message: "permission response applied to outcome callback",
794
+ outcome: "success",
795
+ sessionId: command.session_id,
796
+ toolCallId: command.tool_call_id,
797
+ fields: {
798
+ tool_name: resolver.toolName,
799
+ response_kind: outcome.outcome,
800
+ selected_option: outcome.outcome === "selected" ? outcome.option_id : "cancelled",
801
+ },
802
+ });
402
803
  resolver.onOutcome(outcome);
403
804
  return;
404
805
  }
405
806
  if (!resolver.resolve) {
406
- logPermissionDebug(`response dropped: resolver missing callback session_id=${command.session_id} tool_call_id=${command.tool_call_id}`);
807
+ bridgeLogger.warn({
808
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
809
+ eventName: "permission_response_dropped",
810
+ message: "permission response dropped because resolver callback was missing",
811
+ outcome: "dropped",
812
+ sessionId: command.session_id,
813
+ toolCallId: command.tool_call_id,
814
+ fields: { reason: "missing_resolver_callback" },
815
+ });
407
816
  return;
408
817
  }
409
818
  const selectedOption = outcome.outcome === "selected" ? outcome.option_id : "cancelled";
410
- logPermissionDebug(`response session_id=${command.session_id} tool_call_id=${command.tool_call_id} tool=${resolver.toolName} ` +
411
- `selected=${selectedOption} suggestions=${formatPermissionUpdates(resolver.suggestions)}`);
412
819
  if (outcome.outcome === "selected" &&
413
820
  (outcome.option_id === "allow_once" ||
414
821
  outcome.option_id === "allow_session" ||
@@ -422,45 +829,354 @@ export function handlePermissionResponse(command) {
422
829
  setToolCallStatus(session, command.tool_call_id, "failed", "Permission cancelled");
423
830
  }
424
831
  const permissionResult = permissionResultFromOutcome(outcome, command.tool_call_id, resolver.inputData, resolver.suggestions, resolver.toolName);
425
- if (permissionResult.behavior === "allow") {
426
- logPermissionDebug(`result tool_call_id=${command.tool_call_id} behavior=allow updated_permissions=` +
427
- `${formatPermissionUpdates(permissionResult.updatedPermissions)}`);
428
- }
429
- else {
430
- logPermissionDebug(`result tool_call_id=${command.tool_call_id} behavior=deny message=${permissionResult.message}`);
431
- }
832
+ bridgeLogger.info({
833
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
834
+ eventName: "permission_response_applied",
835
+ message: "permission response applied",
836
+ outcome: "success",
837
+ sessionId: command.session_id,
838
+ toolCallId: command.tool_call_id,
839
+ fields: {
840
+ tool_name: resolver.toolName,
841
+ response_kind: outcome.outcome,
842
+ selected_option: selectedOption,
843
+ behavior: permissionResult.behavior,
844
+ },
845
+ });
432
846
  resolver.resolve(permissionResult);
433
847
  }
434
848
  export function handleQuestionResponse(command) {
849
+ bridgeLogger.info({
850
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
851
+ eventName: "question_response_received",
852
+ message: "question response received",
853
+ outcome: "success",
854
+ sessionId: command.session_id,
855
+ toolCallId: command.tool_call_id,
856
+ fields: { response_kind: command.outcome.outcome },
857
+ });
435
858
  const session = sessionById(command.session_id);
436
859
  if (!session) {
437
- logPermissionDebug(`question response dropped: unknown session session_id=${command.session_id} tool_call_id=${command.tool_call_id}`);
860
+ bridgeLogger.warn({
861
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
862
+ eventName: "question_response_dropped",
863
+ message: "question response dropped for unknown session",
864
+ outcome: "dropped",
865
+ sessionId: command.session_id,
866
+ toolCallId: command.tool_call_id,
867
+ fields: { reason: "unknown_session" },
868
+ });
438
869
  return;
439
870
  }
440
871
  const resolver = session.pendingQuestions.get(command.tool_call_id);
441
872
  if (!resolver) {
442
- logPermissionDebug(`question response dropped: no pending resolver session_id=${command.session_id} tool_call_id=${command.tool_call_id}`);
873
+ bridgeLogger.warn({
874
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
875
+ eventName: "question_response_dropped",
876
+ message: "question response dropped without a pending resolver",
877
+ outcome: "dropped",
878
+ sessionId: command.session_id,
879
+ toolCallId: command.tool_call_id,
880
+ fields: { reason: "missing_pending_resolver" },
881
+ });
443
882
  return;
444
883
  }
445
884
  session.pendingQuestions.delete(command.tool_call_id);
885
+ bridgeLogger.info({
886
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
887
+ eventName: "question_response_applied",
888
+ message: "question response applied",
889
+ outcome: "success",
890
+ sessionId: command.session_id,
891
+ toolCallId: command.tool_call_id,
892
+ fields: {
893
+ tool_name: resolver.toolName,
894
+ response_kind: command.outcome.outcome,
895
+ selected_option_count: command.outcome.outcome === "answered" ? command.outcome.selected_option_ids.length : 0,
896
+ has_annotation: command.outcome.outcome === "answered" && command.outcome.annotation !== undefined,
897
+ },
898
+ });
446
899
  resolver.onOutcome(command.outcome);
447
900
  }
448
901
  export function handleElicitationResponse(command) {
902
+ bridgeLogger.info({
903
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
904
+ eventName: "elicitation_response_received",
905
+ message: "elicitation response received",
906
+ outcome: "success",
907
+ sessionId: command.session_id,
908
+ requestId: command.elicitation_request_id,
909
+ fields: {
910
+ action: command.action,
911
+ has_content: command.content !== undefined,
912
+ },
913
+ });
449
914
  const session = sessionById(command.session_id);
450
915
  if (!session) {
451
- console.error(`[sdk warn] elicitation response dropped: unknown session ` +
452
- `session_id=${command.session_id} request_id=${command.elicitation_request_id}`);
916
+ bridgeLogger.warn({
917
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
918
+ eventName: "elicitation_response_dropped",
919
+ message: "elicitation response dropped for unknown session",
920
+ outcome: "dropped",
921
+ sessionId: command.session_id,
922
+ requestId: command.elicitation_request_id,
923
+ fields: { reason: "unknown_session" },
924
+ });
453
925
  return;
454
926
  }
455
927
  const pending = session.pendingElicitations.get(command.elicitation_request_id);
456
928
  if (!pending) {
457
- console.error(`[sdk warn] elicitation response dropped: no pending request ` +
458
- `session_id=${command.session_id} request_id=${command.elicitation_request_id}`);
929
+ bridgeLogger.warn({
930
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
931
+ eventName: "elicitation_response_dropped",
932
+ message: "elicitation response dropped without pending request",
933
+ outcome: "dropped",
934
+ sessionId: command.session_id,
935
+ requestId: command.elicitation_request_id,
936
+ fields: { reason: "missing_pending_request" },
937
+ });
459
938
  return;
460
939
  }
461
940
  session.pendingElicitations.delete(command.elicitation_request_id);
941
+ bridgeLogger.info({
942
+ target: LOG_TARGETS.BRIDGE_PERMISSION,
943
+ eventName: "elicitation_response_applied",
944
+ message: "elicitation response applied",
945
+ outcome: "success",
946
+ sessionId: command.session_id,
947
+ requestId: command.elicitation_request_id,
948
+ fields: {
949
+ action: command.action,
950
+ server_name: pending.serverName,
951
+ has_content: command.content !== undefined,
952
+ },
953
+ });
462
954
  pending.resolve({
463
955
  action: command.action,
464
- ...(command.content ? { content: command.content } : {}),
956
+ ...(normalizeSdkElicitationContent(command.content) ? {
957
+ content: normalizeSdkElicitationContent(command.content),
958
+ } : {}),
959
+ });
960
+ }
961
+ function normalizeModelKey(id) {
962
+ const original = id.trim();
963
+ if (!original || original === DEFAULT_MODEL_NAME) {
964
+ return { original, family: "default", versionParts: [], variantParts: [] };
965
+ }
966
+ const lower = original.toLowerCase();
967
+ const contextMatch = lower.match(/\[([^\]]+)\]$/);
968
+ const contextSuffix = contextMatch?.[1];
969
+ const withoutContext = contextMatch ? lower.slice(0, contextMatch.index) : lower;
970
+ const withoutPrefix = withoutContext.startsWith("claude-")
971
+ ? withoutContext.slice("claude-".length)
972
+ : withoutContext;
973
+ const parts = withoutPrefix.split("-").filter((part) => part.length > 0);
974
+ const familyPart = parts[0] ?? "";
975
+ const family = familyPart === "opus" || familyPart === "sonnet" || familyPart === "haiku"
976
+ ? familyPart
977
+ : "unknown";
978
+ const versionParts = family === "unknown"
979
+ ? []
980
+ : parts
981
+ .slice(1)
982
+ .filter((part) => /^\d+$/.test(part))
983
+ .map((part) => Number.parseInt(part, 10))
984
+ .filter((part) => Number.isFinite(part));
985
+ const variantParts = family === "unknown"
986
+ ? []
987
+ : parts
988
+ .slice(1)
989
+ .filter((part) => !/^\d+$/.test(part));
990
+ return {
991
+ original,
992
+ family,
993
+ versionParts,
994
+ variantParts,
995
+ ...(contextSuffix ? { contextSuffix } : {}),
996
+ };
997
+ }
998
+ function modelKeysAreCompatible(leftId, rightId) {
999
+ const left = normalizeModelKey(leftId);
1000
+ const right = normalizeModelKey(rightId);
1001
+ if (left.family === "default" || right.family === "default") {
1002
+ return false;
1003
+ }
1004
+ if (left.family === "unknown" || right.family === "unknown") {
1005
+ return left.original.toLowerCase() === right.original.toLowerCase();
1006
+ }
1007
+ if (left.family !== right.family) {
1008
+ return false;
1009
+ }
1010
+ if (left.variantParts.join(".") !== right.variantParts.join(".")) {
1011
+ return false;
1012
+ }
1013
+ if (left.versionParts.length === 0 || right.versionParts.length === 0) {
1014
+ return true;
1015
+ }
1016
+ return left.versionParts.join(".") === right.versionParts.join(".");
1017
+ }
1018
+ function sameContextSuffix(leftId, rightId) {
1019
+ const left = normalizeModelKey(leftId);
1020
+ const right = normalizeModelKey(rightId);
1021
+ return (left.contextSuffix?.toLowerCase() ?? "") === (right.contextSuffix?.toLowerCase() ?? "");
1022
+ }
1023
+ function sameFamilyAndVersion(leftId, rightId) {
1024
+ const left = normalizeModelKey(leftId);
1025
+ const right = normalizeModelKey(rightId);
1026
+ if (left.family === "default" || right.family === "default") {
1027
+ return false;
1028
+ }
1029
+ if (left.family === "unknown" || right.family === "unknown") {
1030
+ return left.original.toLowerCase() === right.original.toLowerCase();
1031
+ }
1032
+ if (left.family !== right.family) {
1033
+ return false;
1034
+ }
1035
+ if (left.versionParts.length === 0 || right.versionParts.length === 0) {
1036
+ return left.versionParts.length === right.versionParts.length;
1037
+ }
1038
+ return left.versionParts.join(".") === right.versionParts.join(".");
1039
+ }
1040
+ function hasVariantSiblingConflict(availableModels, candidateId, resolvedId) {
1041
+ if (sameContextSuffix(candidateId, resolvedId)) {
1042
+ return false;
1043
+ }
1044
+ const resolvedContext = normalizeModelKey(resolvedId).contextSuffix?.toLowerCase() ?? "";
1045
+ if (!resolvedContext) {
1046
+ return false;
1047
+ }
1048
+ return availableModels.some((entry) => {
1049
+ if (entry.id === candidateId) {
1050
+ return false;
1051
+ }
1052
+ if (!sameFamilyAndVersion(entry.id, resolvedId)) {
1053
+ return false;
1054
+ }
1055
+ const entryContext = normalizeModelKey(entry.id).contextSuffix?.toLowerCase() ?? "";
1056
+ return entryContext === resolvedContext;
1057
+ });
1058
+ }
1059
+ function humanizeModelId(id) {
1060
+ const normalized = normalizeModelKey(id);
1061
+ if (normalized.family === "default") {
1062
+ return "Default";
1063
+ }
1064
+ if (normalized.family === "unknown") {
1065
+ return id;
1066
+ }
1067
+ const familyLabel = normalized.family === "opus"
1068
+ ? "Opus"
1069
+ : normalized.family === "sonnet"
1070
+ ? "Sonnet"
1071
+ : "Haiku";
1072
+ const versionLabel = normalized.versionParts.length > 0 ? ` ${normalized.versionParts.join(".")}` : "";
1073
+ const contextLabel = normalized.contextSuffix?.toLowerCase() === "1m"
1074
+ ? " [1M]"
1075
+ : normalized.contextSuffix
1076
+ ? ` [${normalized.contextSuffix}]`
1077
+ : "";
1078
+ return `${familyLabel}${versionLabel}${contextLabel}`;
1079
+ }
1080
+ function shortDisplayNameForModelId(id) {
1081
+ const normalized = normalizeModelKey(id);
1082
+ if (normalized.family === "default") {
1083
+ return "Default";
1084
+ }
1085
+ if (normalized.family === "unknown") {
1086
+ return id;
1087
+ }
1088
+ const familyLabel = normalized.family === "opus"
1089
+ ? "Opus"
1090
+ : normalized.family === "sonnet"
1091
+ ? "Sonnet"
1092
+ : "Haiku";
1093
+ const contextLabel = normalized.contextSuffix?.toLowerCase() === "1m"
1094
+ ? " [1M]"
1095
+ : normalized.contextSuffix
1096
+ ? ` [${normalized.contextSuffix}]`
1097
+ : "";
1098
+ return `${familyLabel}${contextLabel}`;
1099
+ }
1100
+ function currentModelIsAuthoritative(resolvedId, requestedId) {
1101
+ const resolved = resolvedId.trim();
1102
+ if (!resolved || resolved === DEFAULT_MODEL_NAME || resolved === "Connecting...") {
1103
+ return Boolean(requestedId?.trim() && requestedId.trim() !== DEFAULT_MODEL_NAME);
1104
+ }
1105
+ return true;
1106
+ }
1107
+ function resolveCatalogModel(availableModels, resolvedId, requestedId) {
1108
+ const exactResolved = availableModels.find((entry) => entry.id === resolvedId);
1109
+ if (exactResolved) {
1110
+ return exactResolved;
1111
+ }
1112
+ if (requestedId) {
1113
+ const exactRequested = availableModels.find((entry) => entry.id === requestedId);
1114
+ if (exactRequested &&
1115
+ modelKeysAreCompatible(exactRequested.id, resolvedId) &&
1116
+ !hasVariantSiblingConflict(availableModels, exactRequested.id, resolvedId)) {
1117
+ return exactRequested;
1118
+ }
1119
+ }
1120
+ const compatible = availableModels.filter((entry) => modelKeysAreCompatible(entry.id, resolvedId) &&
1121
+ !hasVariantSiblingConflict(availableModels, entry.id, resolvedId));
1122
+ return compatible.length === 1 ? compatible[0] : undefined;
1123
+ }
1124
+ export function resolveCurrentModel(session) {
1125
+ const requestedId = session.requestedModelId?.trim() || undefined;
1126
+ const resolvedId = session.resolvedRuntimeModelId?.trim() ||
1127
+ session.model.trim() ||
1128
+ requestedId ||
1129
+ DEFAULT_MODEL_NAME;
1130
+ const catalogModel = resolveCatalogModel(session.availableModels, resolvedId, requestedId);
1131
+ const runtimeDisplayId = resolvedId || requestedId || DEFAULT_MODEL_NAME;
1132
+ const displayNameShort = shortDisplayNameForModelId(runtimeDisplayId);
1133
+ const displayNameLong = humanizeModelId(runtimeDisplayId);
1134
+ const currentModel = {
1135
+ resolved_id: resolvedId,
1136
+ display_name_short: displayNameShort,
1137
+ display_name_long: displayNameLong,
1138
+ supports_effort: catalogModel?.supports_effort === true,
1139
+ supported_effort_levels: catalogModel?.supported_effort_levels ?? [],
1140
+ is_authoritative: currentModelIsAuthoritative(resolvedId, requestedId),
1141
+ ...(requestedId ? { requested_id: requestedId } : {}),
1142
+ ...(catalogModel ? { catalog_id: catalogModel.id } : {}),
1143
+ ...(catalogModel?.supports_fast_mode !== undefined
1144
+ ? { supports_fast_mode: catalogModel.supports_fast_mode }
1145
+ : {}),
1146
+ ...(catalogModel?.supports_auto_mode !== undefined
1147
+ ? { supports_auto_mode: catalogModel.supports_auto_mode }
1148
+ : {}),
1149
+ ...(catalogModel?.supports_adaptive_thinking !== undefined
1150
+ ? { supports_adaptive_thinking: catalogModel.supports_adaptive_thinking }
1151
+ : {}),
1152
+ };
1153
+ return currentModel;
1154
+ }
1155
+ export function shouldInvalidateResolvedRuntimeModel(previousRequestedId, previousSessionModel, nextRequestedId) {
1156
+ const previousRequested = previousRequestedId?.trim() || previousSessionModel.trim();
1157
+ return previousRequested !== nextRequestedId.trim();
1158
+ }
1159
+ function currentModelsEqual(left, right) {
1160
+ return JSON.stringify(left) === JSON.stringify(right);
1161
+ }
1162
+ export function emitCurrentModelUpdate(session) {
1163
+ if (!session.connected || !session.currentModel) {
1164
+ return false;
1165
+ }
1166
+ emitSessionUpdate(session.sessionId, {
1167
+ type: "current_model_update",
1168
+ current_model: session.currentModel,
465
1169
  });
1170
+ return true;
1171
+ }
1172
+ export function refreshCurrentModel(session, emitUpdate = false) {
1173
+ const nextModel = resolveCurrentModel(session);
1174
+ if (currentModelsEqual(session.currentModel, nextModel)) {
1175
+ return false;
1176
+ }
1177
+ session.currentModel = nextModel;
1178
+ if (emitUpdate) {
1179
+ emitCurrentModelUpdate(session);
1180
+ }
1181
+ return true;
466
1182
  }