agent-relay-runner 0.10.19 → 0.10.21

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 (36) hide show
  1. package/package.json +2 -2
  2. package/plugins/claude/.claude-plugin/plugin.json +4 -1
  3. package/plugins/claude/hooks/hooks.json +114 -0
  4. package/plugins/claude/hooks/permission-request.sh +20 -0
  5. package/plugins/claude/hooks/post-compact.sh +5 -0
  6. package/plugins/claude/hooks/pre-compact.sh +5 -0
  7. package/plugins/claude/hooks/relay-status.sh +66 -0
  8. package/plugins/claude/hooks/session-end.sh +16 -3
  9. package/plugins/claude/hooks/session-start.sh +14 -0
  10. package/plugins/claude/hooks/stop-failure.sh +15 -0
  11. package/plugins/claude/hooks/stop.sh +13 -3
  12. package/plugins/claude/hooks/subagent-start.sh +12 -0
  13. package/plugins/claude/hooks/subagent-stop.sh +12 -0
  14. package/plugins/claude/hooks/user-prompt-submit.sh +2 -3
  15. package/plugins/claude/monitors/relay-monitor.ts +16 -4
  16. package/plugins/claude/skills/react/SKILL.md +18 -0
  17. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  18. package/plugins/claude/skills/reply/SKILL.md +7 -3
  19. package/plugins/codex/skills/guide/SKILL.md +15 -0
  20. package/plugins/codex/skills/react/SKILL.md +17 -0
  21. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  22. package/plugins/codex/skills/reply/SKILL.md +6 -2
  23. package/src/adapter.ts +207 -6
  24. package/src/adapters/claude-delivery.ts +108 -0
  25. package/src/adapters/claude.ts +232 -31
  26. package/src/adapters/codex-client.ts +27 -1
  27. package/src/adapters/codex.ts +635 -26
  28. package/src/attachment-cache.ts +190 -0
  29. package/src/claim-tracker.ts +48 -5
  30. package/src/control-server.ts +193 -6
  31. package/src/index.ts +203 -6
  32. package/src/profile-home.ts +85 -0
  33. package/src/profile-projection.ts +146 -0
  34. package/src/relay-instructions.ts +25 -0
  35. package/src/runner.ts +811 -40
  36. package/src/version.ts +39 -0
@@ -1,13 +1,16 @@
1
- import { resolve } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
2
4
  import type { Message } from "agent-relay-sdk";
3
- import type { ManagedProcess, ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus, SpawnArgs } from "../adapter";
5
+ import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
6
+ import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
4
7
 
5
8
  export class ClaudeAdapter implements ProviderAdapter {
6
9
  readonly provider = "claude";
7
- private statusCb: (status: SemanticStatus) => void = () => {};
10
+ private statusCb: (status: ProviderStatusUpdate) => void = () => {};
8
11
  private tmuxWatcher?: Timer;
9
12
 
10
- onStatusChange(cb: (status: SemanticStatus) => void): void {
13
+ onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
11
14
  this.statusCb = cb;
12
15
  }
13
16
 
@@ -31,44 +34,83 @@ export class ClaudeAdapter implements ProviderAdapter {
31
34
 
32
35
  async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
33
36
  const tmuxSession = process.meta?.tmuxSession as string | undefined;
37
+ const tmuxSocket = process.meta?.tmuxSocket as string | undefined;
34
38
  if (tmuxSession) {
35
- await this.shutdownTmux(tmuxSession, opts);
39
+ await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
36
40
  return;
37
41
  }
38
42
  await terminateProcess(process, opts);
39
43
  }
40
44
 
45
+ async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
46
+ const session = process.meta?.tmuxSession as string | undefined;
47
+ const socket = process.meta?.tmuxSocket as string | undefined;
48
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for compact");
49
+ await submitTextToTmux(session, "/compact", socket);
50
+ return { method: "tmux-inject", command: "/compact" };
51
+ }
52
+
53
+ async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
54
+ const session = process.meta?.tmuxSession as string | undefined;
55
+ const socket = process.meta?.tmuxSocket as string | undefined;
56
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for clear");
57
+ await submitTextToTmux(session, "/clear", socket);
58
+ return { method: "tmux-inject", command: "/clear" };
59
+ }
60
+
41
61
  async deliver(_process: ManagedProcess, messages: Message[]): Promise<void> {
42
62
  const monitor = _process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
43
63
  if (!monitor?.deliver) throw new Error("Claude monitor delivery is unavailable");
44
64
  await monitor.deliver(messages);
45
65
  }
46
66
 
67
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string): Promise<void> {
68
+ const session = process.meta?.tmuxSession as string | undefined;
69
+ const socket = process.meta?.tmuxSocket as string | undefined;
70
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for initial prompt");
71
+ await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
72
+ await submitTextToTmux(session, prompt, socket);
73
+ }
74
+
47
75
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
76
+ const profileHome = prepareClaudeProfileHome(config);
48
77
  const pluginRoot = resolve(import.meta.dir, "../../plugins/claude");
49
- const pluginDirs = [...new Set([pluginRoot, ...providerConfig.pluginDirs])];
78
+ const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
79
+ const pluginDirs = profileAllowsRelayFeature(config, "plugins") ? [...new Set([pluginRoot, ...providerConfig.pluginDirs])] : [];
50
80
  const isClaudeRig = /claude-rig/.test(providerConfig.command);
51
- const rigPrefix = isClaudeRig && config.rig ? ["launch", config.rig] : [];
81
+ const bypassRigDefaults = config.headless && isClaudeRig && config.approvalMode !== "open";
82
+ const rigPrefix = isClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
83
+ const command = bypassRigDefaults || (isClaudeRig && !config.rig && !findClaudeRigRC(config.cwd))
84
+ ? "claude"
85
+ : providerConfig.command;
86
+ const providerArgs = config.headless
87
+ ? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
88
+ : [...defaultArgs, ...config.providerArgs];
52
89
  const args = [
53
90
  ...rigPrefix,
54
91
  ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
55
- ...providerConfig.defaultArgs,
56
- ...config.providerArgs,
92
+ ...(profileAllowsRelayFeature(config, "statusLine") ? sessionStatusLineSettingsArgs(defaultArgs, config.providerArgs) : []),
93
+ ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
94
+ ...providerArgs,
57
95
  ];
58
- if (config.prompt) args.push(String(config.prompt));
96
+ if (config.model) args.push("--model", config.model);
97
+ if (config.prompt && !config.headless) args.push(String(config.prompt));
59
98
  return {
60
- command: providerConfig.command,
99
+ command,
61
100
  args,
62
101
  cwd: config.cwd,
63
102
  env: {
64
103
  ...config.env,
104
+ ...(profileHome ? { CLAUDE_CONFIG_DIR: profileHome.path } : {}),
65
105
  AGENT_RELAY_PROVIDER: "claude",
106
+ ...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
66
107
  },
67
108
  };
68
109
  }
69
110
 
70
- buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; args: string[] } {
71
- const sessionName = tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
111
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[] } {
112
+ const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
113
+ const socketName = tmuxSocketName(sessionName);
72
114
  const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
73
115
  const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
74
116
 
@@ -80,17 +122,17 @@ export class ClaudeAdapter implements ProviderAdapter {
80
122
  }
81
123
 
82
124
  tmuxArgs.push("-c", spawnArgs.cwd, shellCmd);
83
- return { sessionName, args: tmuxArgs };
125
+ return { sessionName, socketName, args: tmuxArgs };
84
126
  }
85
127
 
86
128
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
87
- const { sessionName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
129
+ const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
88
130
 
89
- Bun.spawnSync(["tmux", "kill-session", "-t", sessionName], {
131
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
90
132
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
91
133
  });
92
134
 
93
- const result = Bun.spawnSync(["tmux", ...tmuxArgs], {
135
+ const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
94
136
  stdin: "ignore",
95
137
  stdout: "pipe",
96
138
  stderr: "pipe",
@@ -101,7 +143,15 @@ export class ClaudeAdapter implements ProviderAdapter {
101
143
  throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
102
144
  }
103
145
 
104
- this.watchTmuxSession(sessionName);
146
+ this.watchTmuxSession(sessionName, socketName);
147
+ const logFile = spawnArgs.env.AGENT_RELAY_LOG_FILE;
148
+ if (logFile) {
149
+ Bun.spawnSync(tmuxCommand(socketName, "pipe-pane", "-o", "-t", sessionName, `cat >> ${shellQuote(logFile)}`), {
150
+ stdin: "ignore",
151
+ stdout: "ignore",
152
+ stderr: "ignore",
153
+ });
154
+ }
105
155
 
106
156
  return {
107
157
  pid: undefined,
@@ -109,14 +159,15 @@ export class ClaudeAdapter implements ProviderAdapter {
109
159
  meta: {
110
160
  monitor: config.monitor,
111
161
  tmuxSession: sessionName,
162
+ tmuxSocket: socketName,
112
163
  },
113
164
  };
114
165
  }
115
166
 
116
- private watchTmuxSession(sessionName: string): void {
167
+ private watchTmuxSession(sessionName: string, socketName?: string): void {
117
168
  if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
118
169
  this.tmuxWatcher = setInterval(() => {
119
- if (!tmuxHasSession(sessionName)) {
170
+ if (!tmuxHasSession(sessionName, socketName)) {
120
171
  clearInterval(this.tmuxWatcher!);
121
172
  this.tmuxWatcher = undefined;
122
173
  this.statusCb("offline");
@@ -124,42 +175,180 @@ export class ClaudeAdapter implements ProviderAdapter {
124
175
  }, 2000);
125
176
  }
126
177
 
127
- private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
178
+ private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }, socketName?: string): Promise<void> {
128
179
  if (this.tmuxWatcher) {
129
180
  clearInterval(this.tmuxWatcher);
130
181
  this.tmuxWatcher = undefined;
131
182
  }
132
183
 
133
- if (opts.graceful && tmuxHasSession(sessionName)) {
134
- Bun.spawnSync(["tmux", "send-keys", "-t", sessionName, "C-c"], {
135
- stdin: "ignore", stdout: "ignore", stderr: "ignore",
136
- });
184
+ if (opts.graceful && tmuxHasSession(sessionName, socketName)) {
185
+ await submitTextToTmux(sessionName, CLAUDE_EXIT_COMMAND, socketName);
137
186
 
138
- const deadline = Date.now() + opts.timeoutMs;
187
+ const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
139
188
  while (Date.now() < deadline) {
140
- if (!tmuxHasSession(sessionName)) return;
141
- await Bun.sleep(500);
189
+ if (!tmuxHasSession(sessionName, socketName)) return;
190
+ await Bun.sleep(200);
142
191
  }
143
192
  }
144
193
 
145
- Bun.spawnSync(["tmux", "kill-session", "-t", sessionName], {
194
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
146
195
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
147
196
  });
148
197
  }
149
198
  }
150
199
 
200
+ export const CLAUDE_EXIT_COMMAND = "/exit";
201
+ export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
202
+ export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
203
+ export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
204
+
205
+ export function claudeShutdownGraceMs(timeoutMs: number): number {
206
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
207
+ return Math.min(timeoutMs, 10_000);
208
+ }
209
+
210
+ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
211
+ if (argLists.some(hasSettingsArg)) return [];
212
+ return ["--settings", JSON.stringify({
213
+ statusLine: {
214
+ type: "command",
215
+ command: "agent-relay context-probe --wrap",
216
+ refreshInterval: 30,
217
+ },
218
+ })];
219
+ }
220
+
221
+ export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
222
+ return [
223
+ ...stripClaudePermissionArgs(defaultArgs),
224
+ ...stripClaudePermissionArgs(providerArgs),
225
+ ...claudePermissionModeArgs(approvalMode),
226
+ ];
227
+ }
228
+
229
+ export function claudePermissionModeArgs(approvalMode?: string): string[] {
230
+ if (approvalMode === "open") return ["--dangerously-skip-permissions"];
231
+ if (approvalMode === "read-only") return [
232
+ "--permission-mode",
233
+ "dontAsk",
234
+ "--allowedTools",
235
+ "Read,Grep,Glob,LS,Bash(agent-relay *)",
236
+ ];
237
+ return ["--permission-mode", "default"];
238
+ }
239
+
240
+ function stripClaudePermissionArgs(args: string[]): string[] {
241
+ const result: string[] = [];
242
+ for (let i = 0; i < args.length; i++) {
243
+ const arg = args[i];
244
+ if (!arg) continue;
245
+ if (
246
+ arg === "--dangerously-skip-permissions" ||
247
+ arg === "--allow-dangerously-skip-permissions" ||
248
+ arg?.startsWith("--permission-mode=")
249
+ ) {
250
+ continue;
251
+ }
252
+ if (arg === "--permission-mode") {
253
+ i++;
254
+ continue;
255
+ }
256
+ result.push(arg);
257
+ }
258
+ return result;
259
+ }
260
+
261
+ function hasSettingsArg(args: string[]): boolean {
262
+ return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
263
+ }
264
+
151
265
  export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
152
266
  if (label) return `${prefix}-${label.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase()}`;
153
267
  return `${prefix}-${instanceId.slice(0, 8)}`;
154
268
  }
155
269
 
156
- export function tmuxHasSession(sessionName: string): boolean {
157
- const result = Bun.spawnSync(["tmux", "has-session", "-t", sessionName], {
270
+ export function tmuxSocketName(sessionName: string): string {
271
+ return `agent-relay-${sessionName}`.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase();
272
+ }
273
+
274
+ function tmuxCommand(socketName: string | undefined, ...args: string[]): string[] {
275
+ return socketName ? ["tmux", "-L", socketName, ...args] : ["tmux", ...args];
276
+ }
277
+
278
+ export function tmuxHasSession(sessionName: string, socketName?: string): boolean {
279
+ const result = Bun.spawnSync(tmuxCommand(socketName, "has-session", "-t", sessionName), {
158
280
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
159
281
  });
160
282
  return result.exitCode === 0;
161
283
  }
162
284
 
285
+ function captureTmuxPane(sessionName: string, socketName?: string): string {
286
+ const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
287
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
288
+ });
289
+ if (result.exitCode !== 0) {
290
+ const stderr = result.stderr.toString().trim();
291
+ throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
292
+ }
293
+ return result.stdout.toString();
294
+ }
295
+
296
+ export function claudePaneLooksReady(text: string): boolean {
297
+ return text.includes("Claude Code")
298
+ && (
299
+ text.includes("bypass permissions")
300
+ || text.includes("/effort")
301
+ || text.includes("What's new")
302
+ || text.includes("Welcome back")
303
+ );
304
+ }
305
+
306
+ async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
307
+ const deadline = Date.now() + timeoutMs;
308
+ while (Date.now() < deadline) {
309
+ if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
310
+ if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
311
+ await Bun.sleep(200);
312
+ }
313
+ throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
314
+ }
315
+
316
+ function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
317
+ const bufferName = `agent-relay-${crypto.randomUUID()}`;
318
+ const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
319
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
320
+ });
321
+ if (setResult.exitCode !== 0) {
322
+ const stderr = setResult.stderr.toString().trim();
323
+ throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
324
+ }
325
+ try {
326
+ const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
327
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
328
+ });
329
+ if (pasteResult.exitCode !== 0) {
330
+ const stderr = pasteResult.stderr.toString().trim();
331
+ throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
332
+ }
333
+ } finally {
334
+ Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
335
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
336
+ });
337
+ }
338
+ }
339
+
340
+ async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
341
+ pasteTextIntoTmux(sessionName, text, socketName);
342
+ await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
343
+ const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
344
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
345
+ });
346
+ if (result.exitCode !== 0) {
347
+ const stderr = result.stderr.toString().trim();
348
+ throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
349
+ }
350
+ }
351
+
163
352
  export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
164
353
  const keys = new Set<string>();
165
354
  for (const key of Object.keys(env)) {
@@ -179,6 +368,18 @@ export function shellQuote(arg: string): string {
179
368
  return `'${arg.replace(/'/g, "'\\''")}'`;
180
369
  }
181
370
 
371
+ export function findClaudeRigRC(cwd: string): string | null {
372
+ const home = homedir();
373
+ let dir = resolve(cwd);
374
+ while (true) {
375
+ const rc = join(dir, ".claude-rig");
376
+ if (existsSync(rc)) return rc;
377
+ const parent = resolve(dir, "..");
378
+ if (parent === dir || dir === home) return null;
379
+ dir = parent;
380
+ }
381
+ }
382
+
182
383
  async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
183
384
  const proc = process.process;
184
385
  if (!proc) return;
@@ -168,6 +168,10 @@ export class CodexAppClient {
168
168
  return this.request<ThreadLoadedListResponse>("thread/loaded/list", { limit });
169
169
  }
170
170
 
171
+ async threadCompactStart(threadId: string): Promise<Record<string, never>> {
172
+ return this.request<Record<string, never>>("thread/compact/start", { threadId });
173
+ }
174
+
171
175
  async turnStart(threadId: string, text: string): Promise<TurnStartResponse> {
172
176
  return this.request<TurnStartResponse>("turn/start", {
173
177
  threadId,
@@ -187,6 +191,20 @@ export class CodexAppClient {
187
191
  return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
188
192
  }
189
193
 
194
+ respondToServerRequest(id: JsonRpcId, result: unknown): void {
195
+ if (!this.connected) {
196
+ throw new Error("websocket not connected");
197
+ }
198
+ this.ws.send(JSON.stringify({ id, result }));
199
+ }
200
+
201
+ rejectServerRequest(id: JsonRpcId, code: number, message: string, data?: unknown): void {
202
+ if (!this.connected) {
203
+ throw new Error("websocket not connected");
204
+ }
205
+ this.ws.send(JSON.stringify({ id, error: { code, message, ...(data !== undefined ? { data } : {}) } }));
206
+ }
207
+
190
208
  private async request<T = unknown>(method: string, params?: unknown): Promise<T> {
191
209
  if (!this.connected) {
192
210
  throw new Error("websocket not connected");
@@ -201,7 +219,15 @@ export class CodexAppClient {
201
219
  }
202
220
 
203
221
  private handleMessage(raw: string): void {
204
- const parsed = JSON.parse(raw) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
222
+ // The frame comes from an external process over a WebSocket; a partial or
223
+ // malformed frame must not throw out of onmessage and drop the connection.
224
+ let parsed: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
225
+ try {
226
+ parsed = JSON.parse(raw) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
227
+ } catch {
228
+ this.log(`dropped malformed frame (${raw.length} bytes)`);
229
+ return;
230
+ }
205
231
 
206
232
  if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
207
233
  const pending = this.pending.get(parsed.id);