claude-remote-cli 2.15.15 → 3.0.2
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/bin/claude-remote-cli.js +3 -0
- package/dist/frontend/assets/index-Bz_R9N9S.css +32 -0
- package/dist/frontend/assets/index-Yv6LVq28.js +47 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/index.js +66 -2
- package/dist/server/pty-handler.js +214 -0
- package/dist/server/push.js +54 -3
- package/dist/server/sdk-handler.js +536 -0
- package/dist/server/sessions.js +183 -230
- package/dist/server/types.js +13 -1
- package/dist/server/ws.js +92 -9
- package/dist/test/sessions.test.js +175 -6
- package/package.json +3 -2
- package/dist/frontend/assets/index-B6eOChl9.js +0 -47
- package/dist/frontend/assets/index-XlU0yxtO.css +0 -32
package/dist/server/sessions.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
45
|
+
type,
|
|
79
46
|
agent,
|
|
80
|
-
|
|
81
|
-
repoName: repoName || '',
|
|
47
|
+
repoName,
|
|
82
48
|
repoPath,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
useTmux,
|
|
94
|
-
tmuxSessionName,
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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((
|
|
211
|
-
id,
|
|
212
|
-
type,
|
|
213
|
-
agent,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
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
|
|
163
|
+
const serializedPty = [];
|
|
164
|
+
const serializedSdk = [];
|
|
279
165
|
for (const session of sessions.values()) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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:
|
|
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
|
-
|
|
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 };
|
package/dist/server/types.js
CHANGED
|
@@ -1 +1,13 @@
|
|
|
1
|
-
|
|
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
|
|
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(
|
|
107
|
+
attachToPty(ptySession.pty);
|
|
95
108
|
const ptyReplacedHandler = (newPty) => attachToPty(newPty);
|
|
96
|
-
|
|
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(
|
|
115
|
+
sessions.resize(ptySession.id, parsed.cols, parsed.rows);
|
|
103
116
|
return;
|
|
104
117
|
}
|
|
105
118
|
}
|
|
106
119
|
catch (_) { }
|
|
107
|
-
// Use
|
|
108
|
-
|
|
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 =
|
|
126
|
+
const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
|
|
114
127
|
if (idx !== -1)
|
|
115
|
-
|
|
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
|
});
|