@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.
- package/dist/assets/{AgentsPage-B2NtxJig.js → AgentsPage-Ct9Hvk1J.js} +1 -1
- package/dist/assets/{CronManager-CES2-E0_.js → CronManager-B4XnCjHZ.js} +1 -1
- package/dist/assets/{DashboardPage-aFf4uQfU.js → DashboardPage-CfgavOrA.js} +1 -1
- package/dist/assets/{IntegrationsPage-C_tZ2P0K.js → IntegrationsPage-CmRcLZ2Y.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-DUQik5kh.js → LinearOAuthSettingsPage-D5fdWchh.js} +1 -1
- package/dist/assets/{LinearSettingsPage-DFh8jfhh.js → LinearSettingsPage-BHrdSu6B.js} +1 -1
- package/dist/assets/{Playground-C4t0-Zia.js → Playground-BDjcdHjZ.js} +1 -1
- package/dist/assets/{PromptsPage-Ma4gA0R7.js → PromptsPage-C4XeLDqH.js} +1 -1
- package/dist/assets/{RunsPage-jk2yW392.js → RunsPage-9_OGNofF.js} +1 -1
- package/dist/assets/{SandboxManager-BA6-RlkR.js → SandboxManager-C0UI27DS.js} +1 -1
- package/dist/assets/SettingsPage-1ggRq3DY.js +1 -0
- package/dist/assets/{TailscalePage-CZ6V3kc7.js → TailscalePage-9EDSWy6L.js} +1 -1
- package/dist/assets/{index-B1Oejky6.js → index-DRO5Cupx.js} +3 -3
- package/dist/assets/{sw-register-B9GK06Mo.js → sw-register-UeoQRzHq.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +2 -1
- package/server/claude-adapter.ts +9 -7
- package/server/claude-sdk-adapter.test.ts +298 -0
- package/server/claude-sdk-adapter.ts +394 -0
- package/server/cli-launcher.ts +110 -0
- package/server/routes/settings-routes.ts +10 -0
- package/server/routes.test.ts +27 -0
- package/server/settings-manager.test.ts +21 -0
- package/server/settings-manager.ts +16 -1
- 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
|
+
}
|
package/server/cli-launcher.ts
CHANGED
|
@@ -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
|
});
|