claude-remote-cli 3.0.6 → 3.0.9
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-BBvs0auR.js → index-De_IzAmR.js} +17 -17
- package/dist/frontend/assets/{index-CVH0jxa8.css → index-yTmvRrnt.css} +1 -1
- package/dist/frontend/index.html +2 -2
- package/dist/server/index.js +75 -6
- package/dist/server/pty-handler.js +216 -0
- package/dist/server/push.js +54 -3
- package/dist/server/sdk-handler.js +539 -0
- package/dist/server/sessions.js +191 -263
- package/dist/server/types.js +13 -0
- package/dist/server/ws.js +159 -32
- package/dist/test/branch-rename.test.js +28 -0
- package/dist/test/sessions.test.js +23 -7
- package/package.json +2 -1
package/dist/server/sessions.js
CHANGED
|
@@ -1,260 +1,106 @@
|
|
|
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
|
}
|
|
21
|
+
function offIdleChange(cb) {
|
|
22
|
+
const idx = idleChangeCallbacks.indexOf(cb);
|
|
23
|
+
if (idx !== -1)
|
|
24
|
+
idleChangeCallbacks.splice(idx, 1);
|
|
25
|
+
}
|
|
48
26
|
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, needsBranchRename: paramNeedsBranchRename, branchRenamePrompt: paramBranchRenamePrompt }) {
|
|
49
27
|
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
28
|
+
// Dispatch: if agent is claude, no custom command, try SDK first
|
|
29
|
+
if (agent === 'claude' && !command) {
|
|
30
|
+
const sdkResult = createSdkSession({
|
|
31
|
+
id,
|
|
32
|
+
type,
|
|
33
|
+
agent,
|
|
34
|
+
repoName,
|
|
35
|
+
repoPath,
|
|
36
|
+
cwd,
|
|
37
|
+
root,
|
|
38
|
+
worktreeName,
|
|
39
|
+
branchName,
|
|
40
|
+
displayName,
|
|
41
|
+
}, sessions, idleChangeCallbacks);
|
|
42
|
+
if (!('fallback' in sdkResult)) {
|
|
43
|
+
if (paramNeedsBranchRename) {
|
|
44
|
+
// createSdkSession initializes needsBranchRename to false; set it now
|
|
45
|
+
sessions.get(id).needsBranchRename = true;
|
|
46
|
+
}
|
|
47
|
+
return { ...sdkResult.result, pid: undefined, needsBranchRename: !!paramNeedsBranchRename };
|
|
48
|
+
}
|
|
49
|
+
// SDK init failed — fall through to PTY
|
|
63
50
|
}
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
// Claude Code inside the PTY can generate SIGPIPE when the reading end
|
|
67
|
-
// of the pipe closes, which propagates up and kills the PTY session.
|
|
68
|
-
// Wrapping with `trap '' PIPE; exec ...` makes the shell ignore SIGPIPE
|
|
69
|
-
// before exec'ing the agent, so the agent inherits SIG_IGN for PIPE.
|
|
70
|
-
const wrappedCommand = '/bin/bash';
|
|
71
|
-
const wrappedArgs = ['-c', `trap '' PIPE; exec ${spawnCommand} ${spawnArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`];
|
|
72
|
-
const ptyProcess = pty.spawn(wrappedCommand, wrappedArgs, {
|
|
73
|
-
name: 'xterm-256color',
|
|
74
|
-
cols,
|
|
75
|
-
rows,
|
|
76
|
-
cwd: cwd || repoPath,
|
|
77
|
-
env,
|
|
78
|
-
});
|
|
79
|
-
// Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
|
|
80
|
-
const scrollback = initialScrollback ? [...initialScrollback] : [];
|
|
81
|
-
let scrollbackBytes = initialScrollback ? initialScrollback.reduce((sum, s) => sum + s.length, 0) : 0;
|
|
82
|
-
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
83
|
-
const resolvedCwd = cwd || repoPath;
|
|
84
|
-
const session = {
|
|
51
|
+
// PTY path: codex, terminal, custom command, or SDK fallback
|
|
52
|
+
const ptyParams = {
|
|
85
53
|
id,
|
|
86
|
-
type
|
|
54
|
+
type,
|
|
87
55
|
agent,
|
|
88
|
-
|
|
89
|
-
repoName: repoName || '',
|
|
56
|
+
repoName,
|
|
90
57
|
repoPath,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
useTmux,
|
|
102
|
-
tmuxSessionName,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
restored: paramRestored || false,
|
|
106
|
-
needsBranchRename: paramNeedsBranchRename || false,
|
|
107
|
-
branchRenamePrompt: paramBranchRenamePrompt || '',
|
|
58
|
+
cwd,
|
|
59
|
+
root,
|
|
60
|
+
worktreeName,
|
|
61
|
+
branchName,
|
|
62
|
+
displayName,
|
|
63
|
+
command,
|
|
64
|
+
args,
|
|
65
|
+
cols,
|
|
66
|
+
rows,
|
|
67
|
+
configPath,
|
|
68
|
+
useTmux: paramUseTmux,
|
|
69
|
+
tmuxSessionName: paramTmuxSessionName,
|
|
70
|
+
initialScrollback,
|
|
71
|
+
restored: paramRestored,
|
|
108
72
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const existing = readMeta(configPath, repoPath);
|
|
113
|
-
if (existing && existing.displayName) {
|
|
114
|
-
session.displayName = existing.displayName;
|
|
115
|
-
}
|
|
116
|
-
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
|
|
117
|
-
}
|
|
118
|
-
let metaFlushTimer = null;
|
|
119
|
-
let idleTimer = null;
|
|
120
|
-
function resetIdleTimer() {
|
|
121
|
-
if (session.idle) {
|
|
122
|
-
session.idle = false;
|
|
123
|
-
for (const cb of idleChangeCallbacks)
|
|
124
|
-
cb(session.id, false);
|
|
125
|
-
}
|
|
126
|
-
if (idleTimer)
|
|
127
|
-
clearTimeout(idleTimer);
|
|
128
|
-
idleTimer = setTimeout(() => {
|
|
129
|
-
if (!session.idle) {
|
|
130
|
-
session.idle = true;
|
|
131
|
-
for (const cb of idleChangeCallbacks)
|
|
132
|
-
cb(session.id, true);
|
|
133
|
-
}
|
|
134
|
-
}, IDLE_TIMEOUT_MS);
|
|
73
|
+
const { result, session: ptySession } = createPtySession(ptyParams, sessions, idleChangeCallbacks);
|
|
74
|
+
if (paramNeedsBranchRename) {
|
|
75
|
+
ptySession.needsBranchRename = true;
|
|
135
76
|
}
|
|
136
|
-
|
|
137
|
-
function attachHandlers(proc, canRetry) {
|
|
138
|
-
const spawnTime = Date.now();
|
|
139
|
-
// Clear restored flag after 3s of running — means the PTY is healthy
|
|
140
|
-
const restoredClearTimer = session.restored ? setTimeout(() => { session.restored = false; }, 3000) : null;
|
|
141
|
-
proc.onData((data) => {
|
|
142
|
-
session.lastActivity = new Date().toISOString();
|
|
143
|
-
resetIdleTimer();
|
|
144
|
-
scrollback.push(data);
|
|
145
|
-
scrollbackBytes += data.length;
|
|
146
|
-
// Trim oldest entries if over limit
|
|
147
|
-
while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
|
|
148
|
-
scrollbackBytes -= scrollback.shift().length;
|
|
149
|
-
}
|
|
150
|
-
if (configPath && worktreeName && !metaFlushTimer) {
|
|
151
|
-
metaFlushTimer = setTimeout(() => {
|
|
152
|
-
metaFlushTimer = null;
|
|
153
|
-
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
154
|
-
}, 5000);
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
proc.onExit(() => {
|
|
158
|
-
// If continue args failed quickly, retry without them.
|
|
159
|
-
// Exit code is intentionally not checked: tmux wrapping exits 0 even
|
|
160
|
-
// when the inner command (e.g. claude --continue) fails, because the
|
|
161
|
-
// tmux client doesn't propagate inner exit codes. The 3-second window
|
|
162
|
-
// is the primary heuristic — no user quits a session that fast.
|
|
163
|
-
if (canRetry && (Date.now() - spawnTime) < 3000) {
|
|
164
|
-
const retryArgs = args.filter(a => !continueArgs.includes(a));
|
|
165
|
-
const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
|
|
166
|
-
scrollback.length = 0;
|
|
167
|
-
scrollbackBytes = 0;
|
|
168
|
-
scrollback.push(retryNotice);
|
|
169
|
-
scrollbackBytes = retryNotice.length;
|
|
170
|
-
let retryCommand = resolvedCommand;
|
|
171
|
-
let retrySpawnArgs = retryArgs;
|
|
172
|
-
if (useTmux && tmuxSessionName) {
|
|
173
|
-
const retryTmuxName = tmuxSessionName + '-retry';
|
|
174
|
-
session.tmuxSessionName = retryTmuxName;
|
|
175
|
-
const tmux = resolveTmuxSpawn(resolvedCommand, retryArgs, retryTmuxName);
|
|
176
|
-
retryCommand = tmux.command;
|
|
177
|
-
retrySpawnArgs = tmux.args;
|
|
178
|
-
}
|
|
179
|
-
let retryPty;
|
|
180
|
-
try {
|
|
181
|
-
// Wrap retry spawn with SIGPIPE trap (same as initial spawn)
|
|
182
|
-
const retryWrapped = ['-c', `trap '' PIPE; exec ${retryCommand} ${retrySpawnArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`];
|
|
183
|
-
retryPty = pty.spawn('/bin/bash', retryWrapped, {
|
|
184
|
-
name: 'xterm-256color',
|
|
185
|
-
cols,
|
|
186
|
-
rows,
|
|
187
|
-
cwd: cwd || repoPath,
|
|
188
|
-
env,
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
// Retry spawn failed — fall through to normal exit cleanup
|
|
193
|
-
if (restoredClearTimer)
|
|
194
|
-
clearTimeout(restoredClearTimer);
|
|
195
|
-
if (idleTimer)
|
|
196
|
-
clearTimeout(idleTimer);
|
|
197
|
-
if (metaFlushTimer)
|
|
198
|
-
clearTimeout(metaFlushTimer);
|
|
199
|
-
sessions.delete(id);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
session.pty = retryPty;
|
|
203
|
-
for (const cb of session.onPtyReplacedCallbacks)
|
|
204
|
-
cb(retryPty);
|
|
205
|
-
attachHandlers(retryPty, false);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (restoredClearTimer)
|
|
209
|
-
clearTimeout(restoredClearTimer);
|
|
210
|
-
// If PTY exited and this is a restored session, mark disconnected rather than delete
|
|
211
|
-
if (session.restored) {
|
|
212
|
-
session.status = 'disconnected';
|
|
213
|
-
session.restored = false; // clear so user-initiated kills can delete normally
|
|
214
|
-
if (idleTimer)
|
|
215
|
-
clearTimeout(idleTimer);
|
|
216
|
-
if (metaFlushTimer)
|
|
217
|
-
clearTimeout(metaFlushTimer);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (idleTimer)
|
|
221
|
-
clearTimeout(idleTimer);
|
|
222
|
-
if (metaFlushTimer)
|
|
223
|
-
clearTimeout(metaFlushTimer);
|
|
224
|
-
if (configPath && worktreeName) {
|
|
225
|
-
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
226
|
-
}
|
|
227
|
-
sessions.delete(id);
|
|
228
|
-
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
229
|
-
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
|
|
233
|
-
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, status: 'active' };
|
|
77
|
+
return { ...result, needsBranchRename: ptySession.needsBranchRename };
|
|
234
78
|
}
|
|
235
79
|
function get(id) {
|
|
236
80
|
return sessions.get(id);
|
|
237
81
|
}
|
|
238
82
|
function list() {
|
|
239
83
|
return Array.from(sessions.values())
|
|
240
|
-
.map((
|
|
241
|
-
id,
|
|
242
|
-
type,
|
|
243
|
-
agent,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
84
|
+
.map((s) => ({
|
|
85
|
+
id: s.id,
|
|
86
|
+
type: s.type,
|
|
87
|
+
agent: s.agent,
|
|
88
|
+
mode: s.mode,
|
|
89
|
+
root: s.root,
|
|
90
|
+
repoName: s.repoName,
|
|
91
|
+
repoPath: s.repoPath,
|
|
92
|
+
worktreeName: s.worktreeName,
|
|
93
|
+
branchName: s.branchName,
|
|
94
|
+
displayName: s.displayName,
|
|
95
|
+
createdAt: s.createdAt,
|
|
96
|
+
lastActivity: s.lastActivity,
|
|
97
|
+
idle: s.idle,
|
|
98
|
+
cwd: s.cwd,
|
|
99
|
+
customCommand: s.customCommand,
|
|
100
|
+
useTmux: s.mode === 'pty' ? s.useTmux : false,
|
|
101
|
+
tmuxSessionName: s.mode === 'pty' ? s.tmuxSessionName : '',
|
|
102
|
+
status: s.status,
|
|
103
|
+
needsBranchRename: s.needsBranchRename,
|
|
258
104
|
}))
|
|
259
105
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
260
106
|
}
|
|
@@ -270,20 +116,25 @@ function kill(id) {
|
|
|
270
116
|
if (!session) {
|
|
271
117
|
throw new Error(`Session not found: ${id}`);
|
|
272
118
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
119
|
+
if (session.mode === 'pty') {
|
|
120
|
+
try {
|
|
121
|
+
session.pty.kill('SIGTERM');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// PTY may already be dead (e.g. disconnected sessions) — still delete from registry
|
|
125
|
+
}
|
|
126
|
+
if (session.tmuxSessionName) {
|
|
127
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
128
|
+
}
|
|
278
129
|
}
|
|
279
|
-
if (session.
|
|
280
|
-
|
|
130
|
+
else if (session.mode === 'sdk') {
|
|
131
|
+
killSdkSession(id);
|
|
281
132
|
}
|
|
282
133
|
sessions.delete(id);
|
|
283
134
|
}
|
|
284
135
|
function killAllTmuxSessions() {
|
|
285
136
|
for (const session of sessions.values()) {
|
|
286
|
-
if (session.tmuxSessionName) {
|
|
137
|
+
if (session.mode === 'pty' && session.tmuxSessionName) {
|
|
287
138
|
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
288
139
|
}
|
|
289
140
|
}
|
|
@@ -293,14 +144,25 @@ function resize(id, cols, rows) {
|
|
|
293
144
|
if (!session) {
|
|
294
145
|
throw new Error(`Session not found: ${id}`);
|
|
295
146
|
}
|
|
296
|
-
session.pty
|
|
147
|
+
if (session.mode === 'pty') {
|
|
148
|
+
session.pty.resize(cols, rows);
|
|
149
|
+
}
|
|
150
|
+
// SDK sessions don't support resize (no PTY)
|
|
297
151
|
}
|
|
298
152
|
function write(id, data) {
|
|
299
153
|
const session = sessions.get(id);
|
|
300
154
|
if (!session) {
|
|
301
155
|
throw new Error(`Session not found: ${id}`);
|
|
302
156
|
}
|
|
303
|
-
session.pty
|
|
157
|
+
if (session.mode === 'pty') {
|
|
158
|
+
session.pty.write(data);
|
|
159
|
+
}
|
|
160
|
+
else if (session.mode === 'sdk') {
|
|
161
|
+
sdkSendMessage(id, data);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function handlePermission(id, requestId, approved) {
|
|
165
|
+
sdkHandlePermission(id, requestId, approved);
|
|
304
166
|
}
|
|
305
167
|
function findRepoSession(repoPath) {
|
|
306
168
|
return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
|
|
@@ -311,33 +173,40 @@ function nextTerminalName() {
|
|
|
311
173
|
function serializeAll(configDir) {
|
|
312
174
|
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
313
175
|
fs.mkdirSync(scrollbackDirPath, { recursive: true });
|
|
314
|
-
const
|
|
176
|
+
const serializedPty = [];
|
|
177
|
+
const serializedSdk = [];
|
|
315
178
|
for (const session of sessions.values()) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
179
|
+
if (session.mode === 'pty') {
|
|
180
|
+
// Write scrollback to disk
|
|
181
|
+
const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
|
|
182
|
+
fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
|
|
183
|
+
serializedPty.push({
|
|
184
|
+
id: session.id,
|
|
185
|
+
type: session.type,
|
|
186
|
+
agent: session.agent,
|
|
187
|
+
root: session.root,
|
|
188
|
+
repoName: session.repoName,
|
|
189
|
+
repoPath: session.repoPath,
|
|
190
|
+
worktreeName: session.worktreeName,
|
|
191
|
+
branchName: session.branchName,
|
|
192
|
+
displayName: session.displayName,
|
|
193
|
+
createdAt: session.createdAt,
|
|
194
|
+
lastActivity: session.lastActivity,
|
|
195
|
+
useTmux: session.useTmux,
|
|
196
|
+
tmuxSessionName: session.tmuxSessionName,
|
|
197
|
+
customCommand: session.customCommand,
|
|
198
|
+
cwd: session.cwd,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (session.mode === 'sdk') {
|
|
202
|
+
serializedSdk.push(serializeSdkSession(session));
|
|
203
|
+
}
|
|
336
204
|
}
|
|
337
205
|
const pending = {
|
|
338
206
|
version: 1,
|
|
339
207
|
timestamp: new Date().toISOString(),
|
|
340
|
-
sessions:
|
|
208
|
+
sessions: serializedPty,
|
|
209
|
+
sdkSessions: serializedSdk.length > 0 ? serializedSdk : undefined,
|
|
341
210
|
};
|
|
342
211
|
fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
|
|
343
212
|
}
|
|
@@ -360,6 +229,7 @@ async function restoreFromDisk(configDir) {
|
|
|
360
229
|
}
|
|
361
230
|
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
362
231
|
let restored = 0;
|
|
232
|
+
// Restore PTY sessions
|
|
363
233
|
for (const s of pending.sessions) {
|
|
364
234
|
// Load scrollback from disk
|
|
365
235
|
let initialScrollback;
|
|
@@ -436,6 +306,18 @@ async function restoreFromDisk(configDir) {
|
|
|
436
306
|
}
|
|
437
307
|
catch { /* ignore */ }
|
|
438
308
|
}
|
|
309
|
+
// Restore SDK sessions (as disconnected — they can't resume a live process)
|
|
310
|
+
if (pending.sdkSessions) {
|
|
311
|
+
for (const sdkData of pending.sdkSessions) {
|
|
312
|
+
try {
|
|
313
|
+
restoreSdkSession(sdkData, sessions);
|
|
314
|
+
restored++;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
console.error(`Failed to restore SDK session ${sdkData.id} (${sdkData.displayName})`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
439
321
|
// Clean up
|
|
440
322
|
try {
|
|
441
323
|
fs.unlinkSync(pendingPath);
|
|
@@ -451,9 +333,55 @@ async function restoreFromDisk(configDir) {
|
|
|
451
333
|
function activeTmuxSessionNames() {
|
|
452
334
|
const names = new Set();
|
|
453
335
|
for (const session of sessions.values()) {
|
|
454
|
-
if (session.tmuxSessionName)
|
|
336
|
+
if (session.mode === 'pty' && session.tmuxSessionName)
|
|
455
337
|
names.add(session.tmuxSessionName);
|
|
456
338
|
}
|
|
457
339
|
return names;
|
|
458
340
|
}
|
|
459
|
-
|
|
341
|
+
// SDK idle sweep: check every 60s, terminate SDK sessions idle > 30min, max 5 idle
|
|
342
|
+
let sdkIdleSweepTimer = null;
|
|
343
|
+
function startSdkIdleSweep() {
|
|
344
|
+
if (sdkIdleSweepTimer)
|
|
345
|
+
return;
|
|
346
|
+
sdkIdleSweepTimer = setInterval(() => {
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
const sdkSessions = [];
|
|
349
|
+
for (const session of sessions.values()) {
|
|
350
|
+
if (session.mode === 'sdk') {
|
|
351
|
+
sdkSessions.push(session);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Terminate sessions idle > 30 minutes
|
|
355
|
+
for (const session of sdkSessions) {
|
|
356
|
+
const lastActivity = new Date(session.lastActivity).getTime();
|
|
357
|
+
if (session.idle && (now - lastActivity) > SDK_MAX_IDLE_MS) {
|
|
358
|
+
console.log(`SDK idle sweep: terminating session ${session.id} (${session.displayName}) — idle for ${Math.round((now - lastActivity) / 60000)}min`);
|
|
359
|
+
try {
|
|
360
|
+
kill(session.id);
|
|
361
|
+
}
|
|
362
|
+
catch { /* already dead */ }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// LRU eviction: if more than 5 idle SDK sessions remain, evict oldest
|
|
366
|
+
const idleSdkSessions = Array.from(sessions.values())
|
|
367
|
+
.filter((s) => s.mode === 'sdk' && s.idle)
|
|
368
|
+
.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity));
|
|
369
|
+
while (idleSdkSessions.length > SDK_MAX_IDLE_SESSIONS) {
|
|
370
|
+
const oldest = idleSdkSessions.shift();
|
|
371
|
+
console.log(`SDK idle sweep: evicting session ${oldest.id} (${oldest.displayName}) — LRU`);
|
|
372
|
+
try {
|
|
373
|
+
kill(oldest.id);
|
|
374
|
+
}
|
|
375
|
+
catch { /* already dead */ }
|
|
376
|
+
}
|
|
377
|
+
}, SDK_IDLE_CHECK_INTERVAL_MS);
|
|
378
|
+
}
|
|
379
|
+
function stopSdkIdleSweep() {
|
|
380
|
+
if (sdkIdleSweepTimer) {
|
|
381
|
+
clearInterval(sdkIdleSweepTimer);
|
|
382
|
+
sdkIdleSweepTimer = null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Re-export pty-handler utilities for backward compatibility
|
|
386
|
+
export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
|
|
387
|
+
export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, handlePermission, onIdleChange, offIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, startSdkIdleSweep, stopSdkIdleSweep, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
package/dist/server/types.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
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
|
+
};
|
|
1
14
|
export const MOUNTAIN_NAMES = [
|
|
2
15
|
'everest', 'kilimanjaro', 'denali', 'fuji', 'rainier', 'matterhorn',
|
|
3
16
|
'elbrus', 'aconcagua', 'kangchenjunga', 'lhotse', 'makalu', 'cho-oyu',
|