@hellcoder/companion 0.113.4 → 0.113.5-preview.20260730100656.ecb60c9

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 (26) hide show
  1. package/dist/assets/{AgentsPage-B2NtxJig.js → AgentsPage-Ct9Hvk1J.js} +1 -1
  2. package/dist/assets/{CronManager-CES2-E0_.js → CronManager-B4XnCjHZ.js} +1 -1
  3. package/dist/assets/{DashboardPage-aFf4uQfU.js → DashboardPage-CfgavOrA.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-C_tZ2P0K.js → IntegrationsPage-CmRcLZ2Y.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-DUQik5kh.js → LinearOAuthSettingsPage-D5fdWchh.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-DFh8jfhh.js → LinearSettingsPage-BHrdSu6B.js} +1 -1
  7. package/dist/assets/{Playground-C4t0-Zia.js → Playground-BDjcdHjZ.js} +1 -1
  8. package/dist/assets/{PromptsPage-Ma4gA0R7.js → PromptsPage-C4XeLDqH.js} +1 -1
  9. package/dist/assets/{RunsPage-jk2yW392.js → RunsPage-9_OGNofF.js} +1 -1
  10. package/dist/assets/{SandboxManager-BA6-RlkR.js → SandboxManager-C0UI27DS.js} +1 -1
  11. package/dist/assets/SettingsPage-1ggRq3DY.js +1 -0
  12. package/dist/assets/{TailscalePage-CZ6V3kc7.js → TailscalePage-9EDSWy6L.js} +1 -1
  13. package/dist/assets/{index-B1Oejky6.js → index-DRO5Cupx.js} +3 -3
  14. package/dist/assets/{sw-register-B9GK06Mo.js → sw-register-UeoQRzHq.js} +1 -1
  15. package/dist/index.html +1 -1
  16. package/dist/sw.js +1 -1
  17. package/package.json +2 -1
  18. package/server/claude-adapter.ts +9 -7
  19. package/server/claude-sdk-adapter.test.ts +298 -0
  20. package/server/claude-sdk-adapter.ts +394 -0
  21. package/server/cli-launcher.ts +110 -0
  22. package/server/routes/settings-routes.ts +10 -0
  23. package/server/routes.test.ts +27 -0
  24. package/server/settings-manager.test.ts +21 -0
  25. package/server/settings-manager.ts +16 -1
  26. package/dist/assets/SettingsPage-BehGv-KV.js +0 -1
@@ -0,0 +1,394 @@
1
+ /**
2
+ * SdkClaudeAdapter — the Claude Code transport built on the official
3
+ * `@anthropic-ai/claude-agent-sdk` instead of a hand-rolled stdio bridge.
4
+ *
5
+ * Why this exists: the stdio transport in `ClaudeAdapter` reverse-engineers
6
+ * the CLI's stream-json protocol and owns raw process lifecycle, which is
7
+ * where every transport-level failure this project has debugged lived
8
+ * (spurious stdout EOFs, wedge-kill heuristics, handshake drift across CLI
9
+ * versions). The Agent SDK is Anthropic's maintained implementation of the
10
+ * same protocol: it spawns the same `claude` binary, uses the same
11
+ * subscription credentials, and tracks CLI protocol changes upstream.
12
+ *
13
+ * Design: this class REUSES ClaudeAdapter's entire message brain — routing
14
+ * (`handleRawMessage`), outbound translation (`send` → `handleOutgoing*` →
15
+ * `sendRaw`), recording, and pre-connect queueing — and swaps only the byte
16
+ * transport:
17
+ *
18
+ * inbound: SDK async-generator messages → JSON.stringify → handleRawMessage
19
+ * outbound: sendRaw override parses the NDJSON the base class built and
20
+ * dispatches it to the SDK (input stream / Query control methods)
21
+ *
22
+ * Because the SDK's message objects ARE the CLI's stream-json frames, the
23
+ * bridge and frontend see byte-identical traffic to the stdio transport.
24
+ *
25
+ * Selection is per spawn: `settings.claudeTransport === "sdk"` (see
26
+ * cli-launcher). Switching the setting and hitting Reconnect migrates a live
27
+ * session between transports via `--resume`.
28
+ */
29
+ import { randomUUID } from "node:crypto";
30
+ import {
31
+ query,
32
+ type Options as SdkOptions,
33
+ type PermissionMode,
34
+ type PermissionResult,
35
+ type PermissionUpdate,
36
+ type Query,
37
+ type SDKUserMessage,
38
+ } from "@anthropic-ai/claude-agent-sdk";
39
+ import { ClaudeAdapter } from "./claude-adapter.js";
40
+ import { log } from "./logger.js";
41
+
42
+ export interface SdkAttachOptions {
43
+ model?: string;
44
+ permissionMode?: string;
45
+ /** Reasoning-effort level (low|medium|high|xhigh|max). */
46
+ effort?: string;
47
+ cwd?: string;
48
+ /** Claude-internal session id to resume. */
49
+ resume?: string;
50
+ /** Resume only up to this message UUID (fork points). */
51
+ resumeSessionAt?: string;
52
+ /** Fork to a new session id instead of continuing `resume`. */
53
+ forkSession?: boolean;
54
+ env?: Record<string, string>;
55
+ /** Path to the installed `claude` binary, so the SDK drives the same
56
+ * logged-in CLI (and the same subscription auth) as the stdio transport. */
57
+ claudeBinary?: string;
58
+ }
59
+
60
+ /** Minimal async queue that adapts push-style sends to the SDK's
61
+ * pull-style AsyncIterable input. */
62
+ class AsyncMessageQueue<T> implements AsyncIterable<T> {
63
+ private buffer: T[] = [];
64
+ private waiters: Array<(r: IteratorResult<T>) => void> = [];
65
+ private closed = false;
66
+
67
+ push(item: T): void {
68
+ if (this.closed) return;
69
+ const waiter = this.waiters.shift();
70
+ if (waiter) waiter({ value: item, done: false });
71
+ else this.buffer.push(item);
72
+ }
73
+
74
+ close(): void {
75
+ this.closed = true;
76
+ for (const waiter of this.waiters.splice(0)) {
77
+ waiter({ value: undefined as never, done: true });
78
+ }
79
+ }
80
+
81
+ [Symbol.asyncIterator](): AsyncIterator<T> {
82
+ return {
83
+ next: (): Promise<IteratorResult<T>> => {
84
+ if (this.buffer.length > 0) {
85
+ return Promise.resolve({ value: this.buffer.shift() as T, done: false });
86
+ }
87
+ if (this.closed) {
88
+ return Promise.resolve({ value: undefined as never, done: true });
89
+ }
90
+ return new Promise((resolve) => this.waiters.push(resolve));
91
+ },
92
+ };
93
+ }
94
+ }
95
+
96
+ export class SdkClaudeAdapter extends ClaudeAdapter {
97
+ private sdkQuery: Query | null = null;
98
+ private inputQueue = new AsyncMessageQueue<SDKUserMessage>();
99
+ private abortController = new AbortController();
100
+ private sdkActive = false;
101
+ /** Pending canUseTool resolvers keyed by the synthetic request_id shown to the bridge. */
102
+ private pendingPermissionResolvers = new Map<string, (r: PermissionResult) => void>();
103
+ private exitCb: ((code: number | null) => void) | null = null;
104
+ /** Collects the CLI's stderr (via the SDK callback) for exit classification. */
105
+ private stderrSink: ((data: string) => void) | null = null;
106
+
107
+ constructor(
108
+ sessionId: string,
109
+ opts?: ConstructorParameters<typeof ClaudeAdapter>[1] & {
110
+ onStderr?: (data: string) => void;
111
+ },
112
+ ) {
113
+ super(sessionId, opts);
114
+ this.stderrSink = opts?.onStderr ?? null;
115
+ }
116
+
117
+ /** Launcher registers this to mirror the stdio path's `proc.exited` handling. */
118
+ onExit(cb: (code: number | null) => void): void {
119
+ this.exitCb = cb;
120
+ }
121
+
122
+ /** Hard-stop the underlying CLI (the SDK terminates its child process). */
123
+ abort(): void {
124
+ this.abortController.abort();
125
+ this.inputQueue.close();
126
+ }
127
+
128
+ attachSdk(options: SdkAttachOptions): void {
129
+ this.transportKind = "sdk";
130
+ this.sdkActive = true;
131
+
132
+ const sdkOptions: SdkOptions = {
133
+ abortController: this.abortController,
134
+ includePartialMessages: true,
135
+ ...(options.model ? { model: options.model } : {}),
136
+ ...(options.permissionMode
137
+ ? { permissionMode: options.permissionMode as PermissionMode }
138
+ : {}),
139
+ ...(options.effort ? { effort: options.effort as SdkOptions["effort"] } : {}),
140
+ ...(options.cwd ? { cwd: options.cwd } : {}),
141
+ ...(options.resume ? { resume: options.resume } : {}),
142
+ ...(options.resumeSessionAt ? { resumeSessionAt: options.resumeSessionAt } : {}),
143
+ ...(options.forkSession ? { forkSession: true } : {}),
144
+ ...(options.claudeBinary ? { pathToClaudeCodeExecutable: options.claudeBinary } : {}),
145
+ // Preserve the full server environment: the CLI resolves subscription
146
+ // OAuth credentials itself, exactly like the stdio transport. (Do NOT
147
+ // inject ANTHROPIC_API_KEY here — it would silently switch sessions
148
+ // from subscription windows to metered API billing.)
149
+ env: { ...(process.env as Record<string, string>), ...(options.env ?? {}) },
150
+ stderr: (data: string) => this.stderrSink?.(data),
151
+ canUseTool: (toolName, input, cbOptions) =>
152
+ this.handleCanUseTool(toolName, input, cbOptions),
153
+ };
154
+
155
+ this.sdkQuery = query({ prompt: this.inputQueue, options: sdkOptions });
156
+ void this.pumpMessages(this.sdkQuery);
157
+
158
+ // Flush anything the bridge queued before the transport attached.
159
+ for (const ndjson of this.pendingMessages.splice(0)) {
160
+ this.sendRaw(ndjson);
161
+ }
162
+ }
163
+
164
+ /** Route every SDK message through the same brain as CLI stdout lines. */
165
+ private async pumpMessages(q: Query): Promise<void> {
166
+ let exitCode: number | null = 0;
167
+ try {
168
+ for await (const message of q) {
169
+ this.handleRawMessage(JSON.stringify(message));
170
+ }
171
+ } catch (err) {
172
+ if (this.abortController.signal.aborted) {
173
+ // Intentional kill (launcher/relaunch): report like a SIGTERM'd child.
174
+ exitCode = 143;
175
+ } else {
176
+ exitCode = 1;
177
+ log.error("claude-sdk-adapter", "SDK query terminated with error", {
178
+ sessionId: this.sessionId,
179
+ error: err instanceof Error ? err.message : String(err),
180
+ });
181
+ }
182
+ } finally {
183
+ this.sdkActive = false;
184
+ this.sdkQuery = null;
185
+ this.inputQueue.close();
186
+ for (const resolve of this.pendingPermissionResolvers.values()) {
187
+ resolve({ behavior: "deny", message: "Session ended" });
188
+ }
189
+ this.pendingPermissionResolvers.clear();
190
+ // Same single-shot disconnect contract as the stdio transport.
191
+ if (!this.disconnectFired) {
192
+ this.disconnectFired = true;
193
+ this.disconnectCb?.();
194
+ }
195
+ this.exitCb?.(exitCode);
196
+ }
197
+ }
198
+
199
+ // ── Permissions: SDK callback ⇆ existing bridge round-trip ────────────────
200
+
201
+ /**
202
+ * The SDK surfaces permission prompts as a callback instead of raw
203
+ * `can_use_tool` control_request frames. Re-synthesize the frame the CLI
204
+ * would have sent so the bridge's permission UI, AI validation, and
205
+ * cancellation flows run unchanged; the bridge's eventual
206
+ * `control_response` is intercepted in `sendRaw` and resolves the callback.
207
+ */
208
+ private handleCanUseTool(
209
+ toolName: string,
210
+ input: Record<string, unknown>,
211
+ cbOptions: {
212
+ signal: AbortSignal;
213
+ suggestions?: PermissionUpdate[];
214
+ blockedPath?: string;
215
+ decisionReason?: unknown;
216
+ title?: string;
217
+ tool_use_id?: string;
218
+ },
219
+ ): Promise<PermissionResult> {
220
+ const requestId = `sdkperm-${randomUUID()}`;
221
+ return new Promise<PermissionResult>((resolve) => {
222
+ this.pendingPermissionResolvers.set(requestId, resolve);
223
+
224
+ cbOptions.signal.addEventListener("abort", () => {
225
+ if (!this.pendingPermissionResolvers.delete(requestId)) return;
226
+ // Mirror the CLI's cancel frame so the bridge clears its pending UI.
227
+ this.handleRawMessage(
228
+ JSON.stringify({ type: "control_cancel_request", request_id: requestId }),
229
+ );
230
+ resolve({ behavior: "deny", message: "Request cancelled" });
231
+ });
232
+
233
+ this.handleRawMessage(
234
+ JSON.stringify({
235
+ type: "control_request",
236
+ request_id: requestId,
237
+ request: {
238
+ subtype: "can_use_tool",
239
+ tool_name: toolName,
240
+ input,
241
+ permission_suggestions: cbOptions.suggestions,
242
+ blocked_path: cbOptions.blockedPath,
243
+ decision_reason: cbOptions.decisionReason,
244
+ title: cbOptions.title,
245
+ tool_use_id: cbOptions.tool_use_id,
246
+ },
247
+ }),
248
+ );
249
+ });
250
+ }
251
+
252
+ // ── Transport overrides ───────────────────────────────────────────────────
253
+
254
+ isConnected(): boolean {
255
+ return this.sdkActive;
256
+ }
257
+
258
+ async disconnect(): Promise<void> {
259
+ // Mirrors the stdio contract: mark the transport gone; the launcher owns
260
+ // the hard kill (abort()). Closing the input stream lets the CLI finish
261
+ // its current turn and exit cleanly.
262
+ this.sdkActive = false;
263
+ this.inputQueue.close();
264
+ }
265
+
266
+ /**
267
+ * The base class hands every outbound frame here as NDJSON. Parse it back
268
+ * and dispatch to the SDK: user messages into the input stream, control
269
+ * requests onto the Query's control methods, and permission
270
+ * control_responses into the pending canUseTool resolvers.
271
+ */
272
+ protected sendRaw(ndjson: string): boolean {
273
+ this.recorder?.record(this.sessionId, "out", ndjson, "cli", "claude", "");
274
+
275
+ let msg: Record<string, unknown>;
276
+ try {
277
+ msg = JSON.parse(ndjson) as Record<string, unknown>;
278
+ } catch {
279
+ return false;
280
+ }
281
+
282
+ switch (msg.type) {
283
+ case "user": {
284
+ const m = msg as unknown as { message: SDKUserMessage["message"]; parent_tool_use_id?: string | null };
285
+ this.inputQueue.push({
286
+ type: "user",
287
+ message: m.message,
288
+ parent_tool_use_id: m.parent_tool_use_id ?? null,
289
+ } as SDKUserMessage);
290
+ return true;
291
+ }
292
+
293
+ case "control_response":
294
+ return this.resolvePermissionFromControlResponse(msg);
295
+
296
+ case "control_request":
297
+ return this.dispatchControlRequest(msg);
298
+
299
+ default:
300
+ log.warn("claude-sdk-adapter", "Unsupported outbound frame for SDK transport; dropped", {
301
+ sessionId: this.sessionId,
302
+ frameType: String(msg.type),
303
+ });
304
+ return false;
305
+ }
306
+ }
307
+
308
+ private resolvePermissionFromControlResponse(msg: Record<string, unknown>): boolean {
309
+ const response = (msg as {
310
+ response?: { request_id?: string; response?: Record<string, unknown> };
311
+ }).response;
312
+ const requestId = response?.request_id;
313
+ if (!requestId) return false;
314
+ const resolve = this.pendingPermissionResolvers.get(requestId);
315
+ if (!resolve) {
316
+ // Not a permission reply we own (e.g. a stray late response) — ignore.
317
+ return true;
318
+ }
319
+ this.pendingPermissionResolvers.delete(requestId);
320
+ const inner = response?.response ?? {};
321
+ if (inner.behavior === "allow") {
322
+ resolve({
323
+ behavior: "allow",
324
+ updatedInput: (inner.updatedInput as Record<string, unknown>) ?? {},
325
+ ...(Array.isArray(inner.updatedPermissions) && inner.updatedPermissions.length
326
+ ? { updatedPermissions: inner.updatedPermissions as PermissionUpdate[] }
327
+ : {}),
328
+ });
329
+ } else {
330
+ resolve({
331
+ behavior: "deny",
332
+ message: typeof inner.message === "string" ? inner.message : "Denied by user",
333
+ });
334
+ }
335
+ return true;
336
+ }
337
+
338
+ /** Map the CLI control protocol onto the SDK Query's methods. */
339
+ private dispatchControlRequest(msg: Record<string, unknown>): boolean {
340
+ const requestId = String(msg.request_id ?? "");
341
+ const request = (msg.request ?? {}) as Record<string, unknown>;
342
+ const subtype = String(request.subtype ?? "");
343
+ const q = this.sdkQuery;
344
+ if (!q) return false;
345
+
346
+ const respond = (payload: Record<string, unknown> = {}) => {
347
+ this.handleRawMessage(
348
+ JSON.stringify({
349
+ type: "control_response",
350
+ response: { subtype: "success", request_id: requestId, response: payload },
351
+ }),
352
+ );
353
+ };
354
+ const respondError = (error: string) => {
355
+ this.handleRawMessage(
356
+ JSON.stringify({
357
+ type: "control_response",
358
+ response: { subtype: "error", request_id: requestId, error },
359
+ }),
360
+ );
361
+ };
362
+
363
+ switch (subtype) {
364
+ case "interrupt":
365
+ q.interrupt().then(() => respond()).catch((e) => respondError(String(e)));
366
+ return true;
367
+ case "set_model":
368
+ q.setModel(request.model as string | undefined)
369
+ .then(() => respond())
370
+ .catch((e) => respondError(String(e)));
371
+ return true;
372
+ case "set_permission_mode":
373
+ q.setPermissionMode(request.mode as PermissionMode)
374
+ .then(() => respond())
375
+ .catch((e) => respondError(String(e)));
376
+ return true;
377
+ case "mcp_status":
378
+ q.mcpServerStatus()
379
+ .then((servers) => respond({ mcpServers: servers }))
380
+ .catch((e) => respondError(String(e)));
381
+ return true;
382
+ case "end_session":
383
+ // Graceful shutdown: stop feeding input; the CLI ends on its own.
384
+ this.inputQueue.close();
385
+ respond();
386
+ return true;
387
+ default:
388
+ // Honest failure beats silent drop: the bridge's resolver (if any)
389
+ // gets an error response instead of timing out.
390
+ respondError(`Control subtype "${subtype}" is not supported by the SDK transport`);
391
+ return true;
392
+ }
393
+ }
394
+ }
@@ -15,6 +15,8 @@ import { looksLikeAuthErrorText } from "./session-types.js";
15
15
  import type { RecorderManager } from "./recorder.js";
16
16
  import { CodexAdapter } from "./codex-adapter.js";
17
17
  import { ClaudeAdapter } from "./claude-adapter.js";
18
+ import { SdkClaudeAdapter } from "./claude-sdk-adapter.js";
19
+ import { getSettings } from "./settings-manager.js";
18
20
  import { resolveBinary, getEnrichedPath } from "./path-resolver.js";
19
21
  import { containerManager } from "./container-manager.js";
20
22
  import { companionBus } from "./event-bus.js";
@@ -204,6 +206,10 @@ export class CliLauncher {
204
206
  */
205
207
  private stderrTails = new Map<string, string>();
206
208
  private static readonly STDERR_TAIL_MAX = 4096;
209
+ /** Active SDK-transport adapters (settings.claudeTransport === "sdk"). The
210
+ * SDK owns the child process, so these sessions have no entry in
211
+ * `this.processes`; kill/relaunch go through the adapter's abort(). */
212
+ private sdkAdapters = new Map<string, SdkClaudeAdapter>();
207
213
  private store: SessionStore | null = null;
208
214
  private recorder: RecorderManager | null = null;
209
215
 
@@ -371,6 +377,13 @@ export class CliLauncher {
371
377
  // WS session exit handler, which clears `this.processes`.
372
378
  const oldProc = this.processes.get(sessionId);
373
379
  const oldProxy = this.codexWsProxies.get(sessionId);
380
+ const oldSdk = this.sdkAdapters.get(sessionId);
381
+ if (oldSdk) {
382
+ // Abort tears down the SDK's child; safe to respawn immediately since
383
+ // the new session resumes by id, not by pipe.
384
+ try { oldSdk.abort(); } catch {}
385
+ this.sdkAdapters.delete(sessionId);
386
+ }
374
387
  if (oldProxy) {
375
388
  try {
376
389
  oldProxy.kill("SIGTERM");
@@ -483,6 +496,81 @@ export class CliLauncher {
483
496
  return Array.from(this.sessions.values()).filter((s) => s.state === "starting");
484
497
  }
485
498
 
499
+ /**
500
+ * Launch a Claude session over the official Agent SDK (claude-sdk-adapter).
501
+ * Mirrors the stdio path's contract: same adapter-created event, same
502
+ * session:exited emission (driving proactive keepalive relaunch), same
503
+ * stderr-tail capture for exit classification. The SDK owns the subprocess,
504
+ * so there is no `this.processes` entry and no PID-based liveness.
505
+ */
506
+ private spawnSdk(
507
+ sessionId: string,
508
+ info: SdkSessionInfo,
509
+ options: LaunchOptions & { resumeSessionId?: string },
510
+ binary: string,
511
+ ): void {
512
+ console.log(
513
+ `[cli-launcher] Spawning session ${sessionId} via Agent SDK transport` +
514
+ `${options.resumeSessionId ? ` (resume ${options.resumeSessionId})` : ""}`,
515
+ );
516
+
517
+ const adapter = new SdkClaudeAdapter(sessionId, {
518
+ recorder: this.recorder ?? undefined,
519
+ cwd: info.cwd,
520
+ getStderrTail: () => this.stderrTails.get(sessionId) ?? "",
521
+ onStderr: (text: string) => {
522
+ if (!text) return;
523
+ const merged = (this.stderrTails.get(sessionId) ?? "") + text;
524
+ this.stderrTails.set(
525
+ sessionId,
526
+ merged.length > CliLauncher.STDERR_TAIL_MAX
527
+ ? merged.slice(-CliLauncher.STDERR_TAIL_MAX)
528
+ : merged,
529
+ );
530
+ },
531
+ });
532
+
533
+ const spawnedAt = Date.now();
534
+ adapter.onExit((exitCode) => {
535
+ console.log(`[cli-launcher] Session ${sessionId} exited (code=${exitCode}) [sdk transport]`);
536
+ const session = this.sessions.get(sessionId);
537
+ if (session) {
538
+ session.state = "exited";
539
+ session.exitCode = exitCode ?? -1;
540
+ // Immediate death after resume ⇒ the resume likely failed; start fresh
541
+ // next time. Same heuristic as the stdio path.
542
+ const uptime = Date.now() - spawnedAt;
543
+ if (uptime < 5000 && options.resumeSessionId) {
544
+ console.error(`[cli-launcher] Session ${sessionId} exited immediately after SDK resume (${uptime}ms). Clearing cliSessionId for fresh start.`);
545
+ session.cliSessionId = undefined;
546
+ }
547
+ }
548
+ this.sdkAdapters.delete(sessionId);
549
+ this.persistState();
550
+ const reason = this.classifyExitReason(sessionId, exitCode ?? -1);
551
+ this.stderrTails.delete(sessionId);
552
+ companionBus.emit("session:exited", { sessionId, exitCode: exitCode ?? -1, reason });
553
+ });
554
+
555
+ adapter.attachSdk({
556
+ model: options.model,
557
+ permissionMode: options.permissionMode,
558
+ effort: options.effort,
559
+ cwd: info.cwd,
560
+ resume: options.resumeSessionId,
561
+ resumeSessionAt: options.resumeSessionAt,
562
+ forkSession: options.forkSession,
563
+ env: options.env,
564
+ claudeBinary: binary,
565
+ });
566
+
567
+ this.sdkAdapters.set(sessionId, adapter);
568
+ companionBus.emit("backend:claude-adapter-created", { sessionId, adapter });
569
+ info.state = "connected";
570
+ info.pid = undefined;
571
+ this.persistState();
572
+ }
573
+
486
574
  private spawnCLI(sessionId: string, info: SdkSessionInfo, options: LaunchOptions & { resumeSessionId?: string }): void {
487
575
  const isContainerized = !!options.containerId;
488
576
 
@@ -502,6 +590,15 @@ export class CliLauncher {
502
590
  }
503
591
  }
504
592
 
593
+ // Experimental switchable transport: run this session through the official
594
+ // Agent SDK instead of the hand-rolled stdio bridge. Chosen at spawn time,
595
+ // so flipping the setting + Reconnect migrates a session via --resume.
596
+ // Containerized sessions always use stdio (the SDK cannot docker-exec).
597
+ if (!isContainerized && getSettings().claudeTransport === "sdk") {
598
+ this.spawnSdk(sessionId, info, options, binary);
599
+ return;
600
+ }
601
+
505
602
  let effectivePermissionMode = options.permissionMode;
506
603
  const shouldDowngradeContainerBypass =
507
604
  isContainerized
@@ -1188,6 +1285,19 @@ export class CliLauncher {
1188
1285
  * Kill a session's CLI process.
1189
1286
  */
1190
1287
  async kill(sessionId: string): Promise<boolean> {
1288
+ const sdkAdapter = this.sdkAdapters.get(sessionId);
1289
+ if (sdkAdapter) {
1290
+ // The SDK terminates its child on abort; onExit fires the shared
1291
+ // session:exited handling.
1292
+ sdkAdapter.abort();
1293
+ this.sdkAdapters.delete(sessionId);
1294
+ const sdkSession = this.sessions.get(sessionId);
1295
+ if (sdkSession) {
1296
+ sdkSession.state = "exited";
1297
+ }
1298
+ this.persistState();
1299
+ return true;
1300
+ }
1191
1301
  const proxy = this.codexWsProxies.get(sessionId);
1192
1302
  if (proxy) {
1193
1303
  try { proxy.kill("SIGTERM"); } catch {}
@@ -37,6 +37,7 @@ export function registerSettingsRoutes(api: Hono): void {
37
37
  keepaliveDetachedSessions: settings.keepaliveDetachedSessions,
38
38
  wedgeKillEnabled: settings.wedgeKillEnabled,
39
39
  silenceProbeEnabled: settings.silenceProbeEnabled,
40
+ claudeTransport: settings.claudeTransport ?? "stdio",
40
41
  cliBridgeMode: settings.cliBridgeMode,
41
42
  });
42
43
  });
@@ -144,6 +145,9 @@ export function registerSettingsRoutes(api: Hono): void {
144
145
  if (body.silenceProbeEnabled !== undefined && typeof body.silenceProbeEnabled !== "boolean") {
145
146
  return c.json({ error: "silenceProbeEnabled must be a boolean" }, 400);
146
147
  }
148
+ if (body.claudeTransport !== undefined && body.claudeTransport !== "stdio" && body.claudeTransport !== "sdk") {
149
+ return c.json({ error: 'claudeTransport must be "stdio" or "sdk"' }, 400);
150
+ }
147
151
  if (body.cliBridgeMode !== undefined && body.cliBridgeMode !== "loopback" && body.cliBridgeMode !== "jsonHandoff") {
148
152
  return c.json({ error: "cliBridgeMode must be 'loopback' or 'jsonHandoff'" }, 400);
149
153
  }
@@ -167,6 +171,7 @@ export function registerSettingsRoutes(api: Hono): void {
167
171
  || body.keepaliveDetachedSessions !== undefined
168
172
  || body.wedgeKillEnabled !== undefined
169
173
  || body.silenceProbeEnabled !== undefined
174
+ || body.claudeTransport !== undefined
170
175
  || body.cliBridgeMode !== undefined;
171
176
  if (!hasAnyField) {
172
177
  return c.json({ error: "At least one settings field is required" }, 400);
@@ -293,6 +298,10 @@ export function registerSettingsRoutes(api: Hono): void {
293
298
  typeof body.silenceProbeEnabled === "boolean"
294
299
  ? body.silenceProbeEnabled
295
300
  : undefined,
301
+ claudeTransport:
302
+ body.claudeTransport === "stdio" || body.claudeTransport === "sdk"
303
+ ? (body.claudeTransport as "stdio" | "sdk")
304
+ : undefined,
296
305
  cliBridgeMode:
297
306
  body.cliBridgeMode === "loopback" || body.cliBridgeMode === "jsonHandoff"
298
307
  ? (body.cliBridgeMode as CliBridgeMode)
@@ -329,6 +338,7 @@ export function registerSettingsRoutes(api: Hono): void {
329
338
  keepaliveDetachedSessions: settings.keepaliveDetachedSessions,
330
339
  wedgeKillEnabled: settings.wedgeKillEnabled,
331
340
  silenceProbeEnabled: settings.silenceProbeEnabled,
341
+ claudeTransport: settings.claudeTransport ?? "stdio",
332
342
  cliBridgeMode: settings.cliBridgeMode,
333
343
  });
334
344
  });