claude-remote-cli 2.15.16 → 3.0.3

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.
@@ -1,229 +1,93 @@
1
- import pty from 'node-pty';
2
1
  import crypto from 'node:crypto';
3
2
  import fs from 'node:fs';
4
- import os from 'node:os';
5
3
  import path from 'node:path';
6
4
  import { execFile } from 'node:child_process';
7
5
  import { promisify } from 'node:util';
8
- import { readMeta, writeMeta } from './config.js';
6
+ import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS } from './types.js';
7
+ import { createPtySession } from './pty-handler.js';
8
+ import { createSdkSession, killSdkSession, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission, serializeSdkSession, restoreSdkSession } from './sdk-handler.js';
9
9
  const execFileAsync = promisify(execFile);
10
- const AGENT_COMMANDS = {
11
- claude: 'claude',
12
- codex: 'codex',
13
- };
14
- const AGENT_CONTINUE_ARGS = {
15
- claude: ['--continue'],
16
- codex: ['resume', '--last'],
17
- };
18
- const AGENT_YOLO_ARGS = {
19
- claude: ['--dangerously-skip-permissions'],
20
- codex: ['--full-auto'],
21
- };
22
10
  const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
23
- function generateTmuxSessionName(displayName, id) {
24
- const sanitized = displayName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 30);
25
- return `crc-${sanitized}-${id.slice(0, 8)}`;
26
- }
27
- function resolveTmuxSpawn(command, args, tmuxSessionName) {
28
- return {
29
- command: 'tmux',
30
- args: [
31
- '-u', 'new-session', '-s', tmuxSessionName, '--', command, ...args,
32
- // ';' tokens are tmux command separators — parsed at the top level before
33
- // dispatching to new-session, not passed as argv to `command`.
34
- ';', 'set', 'set-clipboard', 'on',
35
- ';', 'set', 'allow-passthrough', 'on',
36
- ';', 'set', 'mode-keys', 'vi',
37
- ],
38
- };
39
- }
11
+ const SDK_IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
12
+ const SDK_MAX_IDLE_MS = 30 * 60 * 1000; // 30 minutes
13
+ const SDK_MAX_IDLE_SESSIONS = 5;
40
14
  // In-memory registry: id -> Session
41
15
  const sessions = new Map();
42
- const IDLE_TIMEOUT_MS = 5000;
43
16
  let terminalCounter = 0;
44
17
  const idleChangeCallbacks = [];
45
18
  function onIdleChange(cb) {
46
19
  idleChangeCallbacks.push(cb);
47
20
  }
48
- function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, initialScrollback }) {
21
+ function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored }) {
49
22
  const id = providedId || crypto.randomBytes(8).toString('hex');
50
- const createdAt = new Date().toISOString();
51
- const resolvedCommand = command || AGENT_COMMANDS[agent];
52
- // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
53
- const env = Object.assign({}, process.env);
54
- delete env.CLAUDECODE;
55
- const useTmux = !command && !!paramUseTmux;
56
- let spawnCommand = resolvedCommand;
57
- let spawnArgs = args;
58
- const tmuxSessionName = useTmux ? generateTmuxSessionName(displayName || repoName || 'session', id) : '';
59
- if (useTmux) {
60
- const tmux = resolveTmuxSpawn(resolvedCommand, args, tmuxSessionName);
61
- spawnCommand = tmux.command;
62
- spawnArgs = tmux.args;
23
+ // Dispatch: if agent is claude, no custom command, try SDK first
24
+ if (agent === 'claude' && !command) {
25
+ const sdkResult = createSdkSession({
26
+ id,
27
+ type,
28
+ agent,
29
+ repoName,
30
+ repoPath,
31
+ cwd,
32
+ root,
33
+ worktreeName,
34
+ branchName,
35
+ displayName,
36
+ }, sessions, idleChangeCallbacks);
37
+ if (!('fallback' in sdkResult)) {
38
+ return { ...sdkResult.result, pid: undefined };
39
+ }
40
+ // SDK init failed — fall through to PTY
63
41
  }
64
- const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
65
- name: 'xterm-256color',
66
- cols,
67
- rows,
68
- cwd: cwd || repoPath,
69
- env,
70
- });
71
- // Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
72
- const scrollback = initialScrollback ? [...initialScrollback] : [];
73
- let scrollbackBytes = initialScrollback ? initialScrollback.reduce((sum, s) => sum + s.length, 0) : 0;
74
- const MAX_SCROLLBACK = 256 * 1024; // 256KB max
75
- const resolvedCwd = cwd || repoPath;
76
- const session = {
42
+ // PTY path: codex, terminal, custom command, or SDK fallback
43
+ const ptyParams = {
77
44
  id,
78
- type: type || 'worktree',
45
+ type,
79
46
  agent,
80
- root: root || '',
81
- repoName: repoName || '',
47
+ repoName,
82
48
  repoPath,
83
- worktreeName: worktreeName || '',
84
- branchName: branchName || worktreeName || '',
85
- displayName: displayName || worktreeName || repoName || '',
86
- pty: ptyProcess,
87
- createdAt,
88
- lastActivity: createdAt,
89
- scrollback,
90
- idle: false,
91
- cwd: resolvedCwd,
92
- customCommand: command || null,
93
- useTmux,
94
- tmuxSessionName,
95
- onPtyReplacedCallbacks: [],
49
+ cwd,
50
+ root,
51
+ worktreeName,
52
+ branchName,
53
+ displayName,
54
+ command,
55
+ args,
56
+ cols,
57
+ rows,
58
+ configPath,
59
+ useTmux: paramUseTmux,
60
+ tmuxSessionName: paramTmuxSessionName,
61
+ initialScrollback,
62
+ restored: paramRestored,
96
63
  };
97
- sessions.set(id, session);
98
- // Load existing metadata to preserve a previously-set displayName
99
- if (configPath && worktreeName) {
100
- const existing = readMeta(configPath, repoPath);
101
- if (existing && existing.displayName) {
102
- session.displayName = existing.displayName;
103
- }
104
- writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
105
- }
106
- let metaFlushTimer = null;
107
- let idleTimer = null;
108
- function resetIdleTimer() {
109
- if (session.idle) {
110
- session.idle = false;
111
- for (const cb of idleChangeCallbacks)
112
- cb(session.id, false);
113
- }
114
- if (idleTimer)
115
- clearTimeout(idleTimer);
116
- idleTimer = setTimeout(() => {
117
- if (!session.idle) {
118
- session.idle = true;
119
- for (const cb of idleChangeCallbacks)
120
- cb(session.id, true);
121
- }
122
- }, IDLE_TIMEOUT_MS);
123
- }
124
- const continueArgs = AGENT_CONTINUE_ARGS[agent];
125
- function attachHandlers(proc, canRetry) {
126
- const spawnTime = Date.now();
127
- proc.onData((data) => {
128
- session.lastActivity = new Date().toISOString();
129
- resetIdleTimer();
130
- scrollback.push(data);
131
- scrollbackBytes += data.length;
132
- // Trim oldest entries if over limit
133
- while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
134
- scrollbackBytes -= scrollback.shift().length;
135
- }
136
- if (configPath && worktreeName && !metaFlushTimer) {
137
- metaFlushTimer = setTimeout(() => {
138
- metaFlushTimer = null;
139
- writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
140
- }, 5000);
141
- }
142
- });
143
- proc.onExit(() => {
144
- // If continue args failed quickly, retry without them.
145
- // Exit code is intentionally not checked: tmux wrapping exits 0 even
146
- // when the inner command (e.g. claude --continue) fails, because the
147
- // tmux client doesn't propagate inner exit codes. The 3-second window
148
- // is the primary heuristic — no user quits a session that fast.
149
- if (canRetry && (Date.now() - spawnTime) < 3000) {
150
- const retryArgs = args.filter(a => !continueArgs.includes(a));
151
- const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
152
- scrollback.length = 0;
153
- scrollbackBytes = 0;
154
- scrollback.push(retryNotice);
155
- scrollbackBytes = retryNotice.length;
156
- let retryCommand = resolvedCommand;
157
- let retrySpawnArgs = retryArgs;
158
- if (useTmux && tmuxSessionName) {
159
- const retryTmuxName = tmuxSessionName + '-retry';
160
- session.tmuxSessionName = retryTmuxName;
161
- const tmux = resolveTmuxSpawn(resolvedCommand, retryArgs, retryTmuxName);
162
- retryCommand = tmux.command;
163
- retrySpawnArgs = tmux.args;
164
- }
165
- let retryPty;
166
- try {
167
- retryPty = pty.spawn(retryCommand, retrySpawnArgs, {
168
- name: 'xterm-256color',
169
- cols,
170
- rows,
171
- cwd: cwd || repoPath,
172
- env,
173
- });
174
- }
175
- catch {
176
- // Retry spawn failed — fall through to normal exit cleanup
177
- if (idleTimer)
178
- clearTimeout(idleTimer);
179
- if (metaFlushTimer)
180
- clearTimeout(metaFlushTimer);
181
- sessions.delete(id);
182
- return;
183
- }
184
- session.pty = retryPty;
185
- for (const cb of session.onPtyReplacedCallbacks)
186
- cb(retryPty);
187
- attachHandlers(retryPty, false);
188
- return;
189
- }
190
- if (idleTimer)
191
- clearTimeout(idleTimer);
192
- if (metaFlushTimer)
193
- clearTimeout(metaFlushTimer);
194
- if (configPath && worktreeName) {
195
- writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
196
- }
197
- sessions.delete(id);
198
- const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
199
- fs.rm(tmpDir, { recursive: true, force: true }, () => { });
200
- });
201
- }
202
- attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
203
- return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, cwd: resolvedCwd, customCommand: command || null, useTmux, tmuxSessionName };
64
+ const { result } = createPtySession(ptyParams, sessions, idleChangeCallbacks);
65
+ return result;
204
66
  }
205
67
  function get(id) {
206
68
  return sessions.get(id);
207
69
  }
208
70
  function list() {
209
71
  return Array.from(sessions.values())
210
- .map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, cwd, customCommand, useTmux, tmuxSessionName }) => ({
211
- id,
212
- type,
213
- agent,
214
- root,
215
- repoName,
216
- repoPath,
217
- worktreeName,
218
- branchName,
219
- displayName,
220
- createdAt,
221
- lastActivity,
222
- idle,
223
- cwd,
224
- customCommand,
225
- useTmux,
226
- tmuxSessionName,
72
+ .map((s) => ({
73
+ id: s.id,
74
+ type: s.type,
75
+ agent: s.agent,
76
+ mode: s.mode,
77
+ root: s.root,
78
+ repoName: s.repoName,
79
+ repoPath: s.repoPath,
80
+ worktreeName: s.worktreeName,
81
+ branchName: s.branchName,
82
+ displayName: s.displayName,
83
+ createdAt: s.createdAt,
84
+ lastActivity: s.lastActivity,
85
+ idle: s.idle,
86
+ cwd: s.cwd,
87
+ customCommand: s.customCommand,
88
+ useTmux: s.mode === 'pty' ? s.useTmux : false,
89
+ tmuxSessionName: s.mode === 'pty' ? s.tmuxSessionName : '',
90
+ status: s.status,
227
91
  }))
228
92
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
229
93
  }
@@ -239,15 +103,25 @@ function kill(id) {
239
103
  if (!session) {
240
104
  throw new Error(`Session not found: ${id}`);
241
105
  }
242
- session.pty.kill('SIGTERM');
243
- if (session.tmuxSessionName) {
244
- execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
106
+ if (session.mode === 'pty') {
107
+ try {
108
+ session.pty.kill('SIGTERM');
109
+ }
110
+ catch {
111
+ // PTY may already be dead (e.g. disconnected sessions) — still delete from registry
112
+ }
113
+ if (session.tmuxSessionName) {
114
+ execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
115
+ }
116
+ }
117
+ else if (session.mode === 'sdk') {
118
+ killSdkSession(id);
245
119
  }
246
120
  sessions.delete(id);
247
121
  }
248
122
  function killAllTmuxSessions() {
249
123
  for (const session of sessions.values()) {
250
- if (session.tmuxSessionName) {
124
+ if (session.mode === 'pty' && session.tmuxSessionName) {
251
125
  execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
252
126
  }
253
127
  }
@@ -257,14 +131,25 @@ function resize(id, cols, rows) {
257
131
  if (!session) {
258
132
  throw new Error(`Session not found: ${id}`);
259
133
  }
260
- session.pty.resize(cols, rows);
134
+ if (session.mode === 'pty') {
135
+ session.pty.resize(cols, rows);
136
+ }
137
+ // SDK sessions don't support resize (no PTY)
261
138
  }
262
139
  function write(id, data) {
263
140
  const session = sessions.get(id);
264
141
  if (!session) {
265
142
  throw new Error(`Session not found: ${id}`);
266
143
  }
267
- session.pty.write(data);
144
+ if (session.mode === 'pty') {
145
+ session.pty.write(data);
146
+ }
147
+ else if (session.mode === 'sdk') {
148
+ sdkSendMessage(id, data);
149
+ }
150
+ }
151
+ function handlePermission(id, requestId, approved) {
152
+ sdkHandlePermission(id, requestId, approved);
268
153
  }
269
154
  function findRepoSession(repoPath) {
270
155
  return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
@@ -275,33 +160,40 @@ function nextTerminalName() {
275
160
  function serializeAll(configDir) {
276
161
  const scrollbackDirPath = path.join(configDir, 'scrollback');
277
162
  fs.mkdirSync(scrollbackDirPath, { recursive: true });
278
- const serialized = [];
163
+ const serializedPty = [];
164
+ const serializedSdk = [];
279
165
  for (const session of sessions.values()) {
280
- // Write scrollback to disk
281
- const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
282
- fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
283
- serialized.push({
284
- id: session.id,
285
- type: session.type,
286
- agent: session.agent,
287
- root: session.root,
288
- repoName: session.repoName,
289
- repoPath: session.repoPath,
290
- worktreeName: session.worktreeName,
291
- branchName: session.branchName,
292
- displayName: session.displayName,
293
- createdAt: session.createdAt,
294
- lastActivity: session.lastActivity,
295
- useTmux: session.useTmux,
296
- tmuxSessionName: session.tmuxSessionName,
297
- customCommand: session.customCommand,
298
- cwd: session.cwd,
299
- });
166
+ if (session.mode === 'pty') {
167
+ // Write scrollback to disk
168
+ const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
169
+ fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
170
+ serializedPty.push({
171
+ id: session.id,
172
+ type: session.type,
173
+ agent: session.agent,
174
+ root: session.root,
175
+ repoName: session.repoName,
176
+ repoPath: session.repoPath,
177
+ worktreeName: session.worktreeName,
178
+ branchName: session.branchName,
179
+ displayName: session.displayName,
180
+ createdAt: session.createdAt,
181
+ lastActivity: session.lastActivity,
182
+ useTmux: session.useTmux,
183
+ tmuxSessionName: session.tmuxSessionName,
184
+ customCommand: session.customCommand,
185
+ cwd: session.cwd,
186
+ });
187
+ }
188
+ else if (session.mode === 'sdk') {
189
+ serializedSdk.push(serializeSdkSession(session));
190
+ }
300
191
  }
301
192
  const pending = {
302
193
  version: 1,
303
194
  timestamp: new Date().toISOString(),
304
- sessions: serialized,
195
+ sessions: serializedPty,
196
+ sdkSessions: serializedSdk.length > 0 ? serializedSdk : undefined,
305
197
  };
306
198
  fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
307
199
  }
@@ -324,6 +216,7 @@ async function restoreFromDisk(configDir) {
324
216
  }
325
217
  const scrollbackDirPath = path.join(configDir, 'scrollback');
326
218
  let restored = 0;
219
+ // Restore PTY sessions
327
220
  for (const s of pending.sessions) {
328
221
  // Load scrollback from disk
329
222
  let initialScrollback;
@@ -381,6 +274,8 @@ async function restoreFromDisk(configDir) {
381
274
  displayName: s.displayName,
382
275
  args,
383
276
  useTmux: false, // Don't re-wrap in tmux — either attaching to existing or using plain agent
277
+ tmuxSessionName: s.tmuxSessionName,
278
+ restored: true,
384
279
  };
385
280
  if (command)
386
281
  createParams.command = command;
@@ -398,6 +293,18 @@ async function restoreFromDisk(configDir) {
398
293
  }
399
294
  catch { /* ignore */ }
400
295
  }
296
+ // Restore SDK sessions (as disconnected — they can't resume a live process)
297
+ if (pending.sdkSessions) {
298
+ for (const sdkData of pending.sdkSessions) {
299
+ try {
300
+ restoreSdkSession(sdkData, sessions);
301
+ restored++;
302
+ }
303
+ catch {
304
+ console.error(`Failed to restore SDK session ${sdkData.id} (${sdkData.displayName})`);
305
+ }
306
+ }
307
+ }
401
308
  // Clean up
402
309
  try {
403
310
  fs.unlinkSync(pendingPath);
@@ -413,9 +320,55 @@ async function restoreFromDisk(configDir) {
413
320
  function activeTmuxSessionNames() {
414
321
  const names = new Set();
415
322
  for (const session of sessions.values()) {
416
- if (session.tmuxSessionName)
323
+ if (session.mode === 'pty' && session.tmuxSessionName)
417
324
  names.add(session.tmuxSessionName);
418
325
  }
419
326
  return names;
420
327
  }
421
- export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, resolveTmuxSpawn, generateTmuxSessionName };
328
+ // SDK idle sweep: check every 60s, terminate SDK sessions idle > 30min, max 5 idle
329
+ let sdkIdleSweepTimer = null;
330
+ function startSdkIdleSweep() {
331
+ if (sdkIdleSweepTimer)
332
+ return;
333
+ sdkIdleSweepTimer = setInterval(() => {
334
+ const now = Date.now();
335
+ const sdkSessions = [];
336
+ for (const session of sessions.values()) {
337
+ if (session.mode === 'sdk') {
338
+ sdkSessions.push(session);
339
+ }
340
+ }
341
+ // Terminate sessions idle > 30 minutes
342
+ for (const session of sdkSessions) {
343
+ const lastActivity = new Date(session.lastActivity).getTime();
344
+ if (session.idle && (now - lastActivity) > SDK_MAX_IDLE_MS) {
345
+ console.log(`SDK idle sweep: terminating session ${session.id} (${session.displayName}) — idle for ${Math.round((now - lastActivity) / 60000)}min`);
346
+ try {
347
+ kill(session.id);
348
+ }
349
+ catch { /* already dead */ }
350
+ }
351
+ }
352
+ // LRU eviction: if more than 5 idle SDK sessions remain, evict oldest
353
+ const idleSdkSessions = Array.from(sessions.values())
354
+ .filter((s) => s.mode === 'sdk' && s.idle)
355
+ .sort((a, b) => a.lastActivity.localeCompare(b.lastActivity));
356
+ while (idleSdkSessions.length > SDK_MAX_IDLE_SESSIONS) {
357
+ const oldest = idleSdkSessions.shift();
358
+ console.log(`SDK idle sweep: evicting session ${oldest.id} (${oldest.displayName}) — LRU`);
359
+ try {
360
+ kill(oldest.id);
361
+ }
362
+ catch { /* already dead */ }
363
+ }
364
+ }, SDK_IDLE_CHECK_INTERVAL_MS);
365
+ }
366
+ function stopSdkIdleSweep() {
367
+ if (sdkIdleSweepTimer) {
368
+ clearInterval(sdkIdleSweepTimer);
369
+ sdkIdleSweepTimer = null;
370
+ }
371
+ }
372
+ // Re-export pty-handler utilities for backward compatibility
373
+ export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
374
+ export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, handlePermission, onIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, startSdkIdleSweep, stopSdkIdleSweep, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
@@ -1 +1,13 @@
1
- export {};
1
+ // Agent command records (shared by PTY and SDK handlers)
2
+ export const AGENT_COMMANDS = {
3
+ claude: 'claude',
4
+ codex: 'codex',
5
+ };
6
+ export const AGENT_CONTINUE_ARGS = {
7
+ claude: ['--continue'],
8
+ codex: ['resume', '--last'],
9
+ };
10
+ export const AGENT_YOLO_ARGS = {
11
+ claude: ['--dangerously-skip-permissions'],
12
+ codex: ['--full-auto'],
13
+ };
package/dist/server/ws.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import * as sessions from './sessions.js';
3
+ import { onSdkEvent, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission } from './sdk-handler.js';
4
+ const BACKPRESSURE_HIGH = 1024 * 1024; // 1MB
5
+ const BACKPRESSURE_LOW = 512 * 1024; // 512KB
3
6
  function parseCookies(cookieHeader) {
4
7
  const cookies = {};
5
8
  if (!cookieHeader)
@@ -47,7 +50,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
47
50
  });
48
51
  return;
49
52
  }
50
- // PTY channel: /ws/:sessionId
53
+ // PTY/SDK channel: /ws/:sessionId
51
54
  const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
52
55
  if (!match) {
53
56
  socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
@@ -71,6 +74,16 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
71
74
  const session = sessionMap.get(ws);
72
75
  if (!session)
73
76
  return;
77
+ if (session.mode === 'sdk') {
78
+ handleSdkConnection(ws, session);
79
+ return;
80
+ }
81
+ // PTY mode — existing behavior
82
+ if (session.mode !== 'pty') {
83
+ ws.close(1008, 'Session mode does not support PTY streaming');
84
+ return;
85
+ }
86
+ const ptySession = session;
74
87
  let dataDisposable = null;
75
88
  let exitDisposable = null;
76
89
  function attachToPty(ptyProcess) {
@@ -78,7 +91,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
78
91
  dataDisposable?.dispose();
79
92
  exitDisposable?.dispose();
80
93
  // Replay scrollback
81
- for (const chunk of session.scrollback) {
94
+ for (const chunk of ptySession.scrollback) {
82
95
  if (ws.readyState === ws.OPEN)
83
96
  ws.send(chunk);
84
97
  }
@@ -91,30 +104,100 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
91
104
  ws.close(1000);
92
105
  });
93
106
  }
94
- attachToPty(session.pty);
107
+ attachToPty(ptySession.pty);
95
108
  const ptyReplacedHandler = (newPty) => attachToPty(newPty);
96
- session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
109
+ ptySession.onPtyReplacedCallbacks.push(ptyReplacedHandler);
97
110
  ws.on('message', (msg) => {
98
111
  const str = msg.toString();
99
112
  try {
100
113
  const parsed = JSON.parse(str);
101
114
  if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
102
- sessions.resize(session.id, parsed.cols, parsed.rows);
115
+ sessions.resize(ptySession.id, parsed.cols, parsed.rows);
103
116
  return;
104
117
  }
105
118
  }
106
119
  catch (_) { }
107
- // Use session.pty dynamically so writes go to current PTY
108
- session.pty.write(str);
120
+ // Use ptySession.pty dynamically so writes go to current PTY
121
+ ptySession.pty.write(str);
109
122
  });
110
123
  ws.on('close', () => {
111
124
  dataDisposable?.dispose();
112
125
  exitDisposable?.dispose();
113
- const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
126
+ const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
114
127
  if (idx !== -1)
115
- session.onPtyReplacedCallbacks.splice(idx, 1);
128
+ ptySession.onPtyReplacedCallbacks.splice(idx, 1);
116
129
  });
117
130
  });
131
+ function handleSdkConnection(ws, session) {
132
+ // Send session info
133
+ const sessionInfo = JSON.stringify({
134
+ type: 'session_info',
135
+ mode: 'sdk',
136
+ sessionId: session.id,
137
+ });
138
+ if (ws.readyState === ws.OPEN)
139
+ ws.send(sessionInfo);
140
+ // Replay stored events (send as-is — client expects raw SdkEvent shape)
141
+ for (const event of session.events) {
142
+ if (ws.readyState !== ws.OPEN)
143
+ break;
144
+ ws.send(JSON.stringify(event));
145
+ }
146
+ // Subscribe to live events with backpressure
147
+ let paused = false;
148
+ const unsubscribe = onSdkEvent(session.id, (event) => {
149
+ if (ws.readyState !== ws.OPEN)
150
+ return;
151
+ // Backpressure check
152
+ if (ws.bufferedAmount > BACKPRESSURE_HIGH) {
153
+ paused = true;
154
+ return;
155
+ }
156
+ ws.send(JSON.stringify(event));
157
+ });
158
+ // Periodically check if we can resume
159
+ const backpressureInterval = setInterval(() => {
160
+ if (paused && ws.bufferedAmount < BACKPRESSURE_LOW) {
161
+ paused = false;
162
+ }
163
+ }, 100);
164
+ // Handle incoming messages
165
+ ws.on('message', (msg) => {
166
+ const str = msg.toString();
167
+ try {
168
+ const parsed = JSON.parse(str);
169
+ if (parsed.type === 'message' && typeof parsed.text === 'string') {
170
+ if (parsed.text.length > 100_000)
171
+ return;
172
+ sdkSendMessage(session.id, parsed.text);
173
+ return;
174
+ }
175
+ if (parsed.type === 'permission' && typeof parsed.requestId === 'string' && typeof parsed.approved === 'boolean') {
176
+ sdkHandlePermission(session.id, parsed.requestId, parsed.approved);
177
+ return;
178
+ }
179
+ if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
180
+ // TODO: wire up companion shell — currently open_companion message is unhandled server-side
181
+ return;
182
+ }
183
+ if (parsed.type === 'open_companion') {
184
+ // TODO: spawn companion PTY in session CWD and relay via terminal_data/terminal_exit frames
185
+ return;
186
+ }
187
+ }
188
+ catch (_) {
189
+ // Not JSON — ignore for SDK sessions
190
+ }
191
+ });
192
+ ws.on('close', () => {
193
+ unsubscribe();
194
+ clearInterval(backpressureInterval);
195
+ });
196
+ ws.on('error', () => {
197
+ unsubscribe();
198
+ clearInterval(backpressureInterval);
199
+ });
200
+ }
118
201
  sessions.onIdleChange((sessionId, idle) => {
119
202
  broadcastEvent('session-idle-changed', { sessionId, idle });
120
203
  });