claude-remote-cli 3.2.0 → 3.3.0
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/frontend/assets/{index-H6FwqbR7.css → index-BNLfnaOa.css} +2 -2
- package/dist/frontend/assets/index-ggOT9Hda.js +47 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/git.js +16 -1
- package/dist/server/index.js +20 -63
- package/dist/server/push.js +1 -32
- package/dist/server/sessions.js +89 -133
- package/dist/server/types.js +1 -1
- package/dist/server/ws.js +1 -93
- package/package.json +1 -2
- package/dist/frontend/assets/index-CzJETpYJ.js +0 -47
- package/dist/server/sdk-handler.js +0 -539
package/dist/frontend/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<meta name="theme-color" content="#1a1a1a" />
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-ggOT9Hda.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BNLfnaOa.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="app"></div>
|
package/dist/server/git.js
CHANGED
|
@@ -257,4 +257,19 @@ async function getUnresolvedCommentCount(repoPath, prNumber, options = {}) {
|
|
|
257
257
|
return 0;
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
-
|
|
260
|
+
async function getWorkingTreeDiff(repoPath, exec = execFileAsync) {
|
|
261
|
+
try {
|
|
262
|
+
const { stdout } = await exec('git', ['diff', '--shortstat'], { cwd: repoPath, timeout: 5000 });
|
|
263
|
+
// Output like: " 3 files changed, 55 insertions(+), 12 deletions(-)"
|
|
264
|
+
const insertions = stdout.match(/(\d+) insertion/);
|
|
265
|
+
const deletions = stdout.match(/(\d+) deletion/);
|
|
266
|
+
return {
|
|
267
|
+
additions: insertions?.[1] ? parseInt(insertions[1], 10) : 0,
|
|
268
|
+
deletions: deletions?.[1] ? parseInt(deletions[1], 10) : 0,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return { additions: 0, deletions: 0 };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export { listBranches, normalizeBranchNames, getActivityFeed, getCiStatus, getPrForBranch, getUnresolvedCommentCount, switchBranch, getCommitsAhead, getCurrentBranch, getWorkingTreeDiff, };
|
package/dist/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import cookieParser from 'cookie-parser';
|
|
|
11
11
|
import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir } from './config.js';
|
|
12
12
|
import * as auth from './auth.js';
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
|
-
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames,
|
|
14
|
+
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache } from './sessions.js';
|
|
15
15
|
import { setupWebSocket } from './ws.js';
|
|
16
16
|
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
17
17
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
@@ -134,12 +134,6 @@ async function main() {
|
|
|
134
134
|
config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
|
|
135
135
|
if (process.env.CLAUDE_REMOTE_HOST)
|
|
136
136
|
config.host = process.env.CLAUDE_REMOTE_HOST;
|
|
137
|
-
// Enable SDK debug logging if requested
|
|
138
|
-
if (process.env.CLAUDE_REMOTE_DEBUG_LOG === '1' || config.debugLog) {
|
|
139
|
-
const { enableDebugLog } = await import('./sdk-handler.js');
|
|
140
|
-
enableDebugLog(true);
|
|
141
|
-
console.log('SDK debug logging enabled → ~/.config/claude-remote-cli/debug/');
|
|
142
|
-
}
|
|
143
137
|
push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
|
|
144
138
|
if (!config.pinHash) {
|
|
145
139
|
const pin = await promptPin('Set up a PIN for claude-remote-cli:');
|
|
@@ -216,6 +210,8 @@ async function main() {
|
|
|
216
210
|
if (restoredCount > 0) {
|
|
217
211
|
console.log(`Restored ${restoredCount} session(s) from previous update.`);
|
|
218
212
|
}
|
|
213
|
+
// Populate session metadata cache in background (non-blocking)
|
|
214
|
+
populateMetaCache().catch(() => { });
|
|
219
215
|
// Push notifications on session idle
|
|
220
216
|
sessions.onIdleChange((sessionId, idle) => {
|
|
221
217
|
if (idle) {
|
|
@@ -259,6 +255,22 @@ async function main() {
|
|
|
259
255
|
app.get('/sessions', requireAuth, (_req, res) => {
|
|
260
256
|
res.json(sessions.list());
|
|
261
257
|
});
|
|
258
|
+
// GET /sessions/meta — bulk metadata for all sessions (cached)
|
|
259
|
+
app.get('/sessions/meta', requireAuth, (_req, res) => {
|
|
260
|
+
res.json(getAllSessionMeta());
|
|
261
|
+
});
|
|
262
|
+
// GET /sessions/:id/meta — individual session metadata
|
|
263
|
+
app.get('/sessions/:id/meta', requireAuth, async (req, res) => {
|
|
264
|
+
const id = req.params.id ?? '';
|
|
265
|
+
const refresh = req.query.refresh === 'true';
|
|
266
|
+
try {
|
|
267
|
+
const meta = await getSessionMeta(id, refresh);
|
|
268
|
+
res.json(meta ?? { prNumber: null, additions: 0, deletions: 0, fetchedAt: new Date().toISOString() });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
res.json({ prNumber: null, additions: 0, deletions: 0, fetchedAt: new Date().toISOString() });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
262
274
|
// GET /branches?repo=<path> — list local and remote branches for a repo
|
|
263
275
|
app.get('/branches', requireAuth, async (req, res) => {
|
|
264
276
|
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
@@ -732,58 +744,6 @@ async function main() {
|
|
|
732
744
|
res.status(404).json({ error: 'Session not found' });
|
|
733
745
|
}
|
|
734
746
|
});
|
|
735
|
-
// POST /sessions/:id/message — send message to SDK session
|
|
736
|
-
app.post('/sessions/:id/message', requireAuth, (req, res) => {
|
|
737
|
-
const id = req.params['id'];
|
|
738
|
-
const { text } = req.body;
|
|
739
|
-
if (!text) {
|
|
740
|
-
res.status(400).json({ error: 'text is required' });
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
const session = sessions.get(id);
|
|
744
|
-
if (!session) {
|
|
745
|
-
res.status(404).json({ error: 'Session not found' });
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
if (session.mode !== 'sdk') {
|
|
749
|
-
res.status(400).json({ error: 'Session is not an SDK session — use WebSocket for PTY sessions' });
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
try {
|
|
753
|
-
sessions.write(id, text);
|
|
754
|
-
res.json({ ok: true });
|
|
755
|
-
}
|
|
756
|
-
catch (err) {
|
|
757
|
-
const message = err instanceof Error ? err.message : 'Failed to send message';
|
|
758
|
-
res.status(500).json({ error: message });
|
|
759
|
-
}
|
|
760
|
-
});
|
|
761
|
-
// POST /sessions/:id/permission — handle permission approval for SDK session
|
|
762
|
-
app.post('/sessions/:id/permission', requireAuth, (req, res) => {
|
|
763
|
-
const id = req.params['id'];
|
|
764
|
-
const { requestId, approved } = req.body;
|
|
765
|
-
if (!requestId || typeof approved !== 'boolean') {
|
|
766
|
-
res.status(400).json({ error: 'requestId and approved are required' });
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
const session = sessions.get(id);
|
|
770
|
-
if (!session) {
|
|
771
|
-
res.status(404).json({ error: 'Session not found' });
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (session.mode !== 'sdk') {
|
|
775
|
-
res.status(400).json({ error: 'Session is not an SDK session' });
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
try {
|
|
779
|
-
sessions.handlePermission(id, requestId, approved);
|
|
780
|
-
res.json({ ok: true });
|
|
781
|
-
}
|
|
782
|
-
catch (err) {
|
|
783
|
-
const message = err instanceof Error ? err.message : 'Failed to handle permission';
|
|
784
|
-
res.status(500).json({ error: message });
|
|
785
|
-
}
|
|
786
|
-
});
|
|
787
747
|
// PATCH /sessions/:id — update displayName and persist to metadata
|
|
788
748
|
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
789
749
|
const { displayName } = req.body;
|
|
@@ -891,15 +851,12 @@ async function main() {
|
|
|
891
851
|
catch {
|
|
892
852
|
// tmux not installed or no sessions — ignore
|
|
893
853
|
}
|
|
894
|
-
// Start SDK idle sweep
|
|
895
|
-
startSdkIdleSweep();
|
|
896
854
|
function gracefulShutdown() {
|
|
897
855
|
server.close();
|
|
898
|
-
stopSdkIdleSweep();
|
|
899
856
|
// Serialize sessions to disk BEFORE killing them
|
|
900
857
|
const configDir = path.dirname(CONFIG_PATH);
|
|
901
858
|
serializeAll(configDir);
|
|
902
|
-
// Kill all active sessions (PTY + tmux
|
|
859
|
+
// Kill all active sessions (PTY + tmux)
|
|
903
860
|
for (const s of sessions.list()) {
|
|
904
861
|
try {
|
|
905
862
|
sessions.kill(s.id);
|
package/dist/server/push.js
CHANGED
|
@@ -40,33 +40,6 @@ export function removeSession(sessionId) {
|
|
|
40
40
|
entry.sessionIds.delete(sessionId);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
export function enrichNotification(event) {
|
|
44
|
-
try {
|
|
45
|
-
switch (event.type) {
|
|
46
|
-
case 'tool_call': {
|
|
47
|
-
const action = event.toolName || 'use a tool';
|
|
48
|
-
const target = event.path || (event.toolInput && typeof event.toolInput === 'object'
|
|
49
|
-
? event.toolInput.file_path || event.toolInput.command || ''
|
|
50
|
-
: '');
|
|
51
|
-
const msg = target
|
|
52
|
-
? `Claude wants to ${action} ${target}`
|
|
53
|
-
: `Claude wants to ${action}`;
|
|
54
|
-
return msg.slice(0, 200);
|
|
55
|
-
}
|
|
56
|
-
case 'turn_completed':
|
|
57
|
-
return 'Claude finished';
|
|
58
|
-
case 'error': {
|
|
59
|
-
const brief = (event.text || 'unknown error').slice(0, 150);
|
|
60
|
-
return `Claude hit an error: ${brief}`;
|
|
61
|
-
}
|
|
62
|
-
default:
|
|
63
|
-
return 'Claude is waiting for your input';
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return 'Claude is waiting for your input';
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
43
|
function truncatePayload(payload) {
|
|
71
44
|
if (payload.length <= MAX_PAYLOAD_SIZE)
|
|
72
45
|
return payload;
|
|
@@ -85,19 +58,15 @@ function truncatePayload(payload) {
|
|
|
85
58
|
}
|
|
86
59
|
return payload.slice(0, MAX_PAYLOAD_SIZE);
|
|
87
60
|
}
|
|
88
|
-
export function notifySessionIdle(sessionId, session
|
|
61
|
+
export function notifySessionIdle(sessionId, session) {
|
|
89
62
|
if (!vapidPublicKey)
|
|
90
63
|
return;
|
|
91
|
-
const enrichedMessage = sdkEvent ? enrichNotification(sdkEvent) : undefined;
|
|
92
64
|
const payloadObj = {
|
|
93
65
|
type: 'session-attention',
|
|
94
66
|
sessionId,
|
|
95
67
|
displayName: session.displayName,
|
|
96
68
|
sessionType: session.type,
|
|
97
69
|
};
|
|
98
|
-
if (enrichedMessage) {
|
|
99
|
-
payloadObj.enrichedMessage = enrichedMessage;
|
|
100
|
-
}
|
|
101
70
|
const payload = truncatePayload(JSON.stringify(payloadObj));
|
|
102
71
|
for (const [endpoint, entry] of subscriptions) {
|
|
103
72
|
if (!entry.sessionIds.has(sessionId))
|
package/dist/server/sessions.js
CHANGED
|
@@ -5,14 +5,13 @@ import { execFile } from 'node:child_process';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS } from './types.js';
|
|
7
7
|
import { createPtySession } from './pty-handler.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getPrForBranch, getWorkingTreeDiff } from './git.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
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;
|
|
14
11
|
// In-memory registry: id -> Session
|
|
15
12
|
const sessions = new Map();
|
|
13
|
+
// Session metadata cache: session ID or worktree path -> SessionMeta
|
|
14
|
+
const metaCache = new Map();
|
|
16
15
|
let terminalCounter = 0;
|
|
17
16
|
const idleChangeCallbacks = [];
|
|
18
17
|
function onIdleChange(cb) {
|
|
@@ -28,26 +27,7 @@ function fireSessionEnd(sessionId, repoPath, branchName) {
|
|
|
28
27
|
}
|
|
29
28
|
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 }) {
|
|
30
29
|
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
31
|
-
//
|
|
32
|
-
if (agent === 'claude' && !command) {
|
|
33
|
-
const sdkResult = createSdkSession({
|
|
34
|
-
id,
|
|
35
|
-
type,
|
|
36
|
-
agent,
|
|
37
|
-
repoName,
|
|
38
|
-
repoPath,
|
|
39
|
-
cwd,
|
|
40
|
-
root,
|
|
41
|
-
worktreeName,
|
|
42
|
-
branchName,
|
|
43
|
-
displayName,
|
|
44
|
-
}, sessions, idleChangeCallbacks);
|
|
45
|
-
if (!('fallback' in sdkResult)) {
|
|
46
|
-
return { ...sdkResult.result, pid: undefined, needsBranchRename: false };
|
|
47
|
-
}
|
|
48
|
-
// SDK init failed — fall through to PTY
|
|
49
|
-
}
|
|
50
|
-
// PTY path: codex, terminal, custom command, or SDK fallback
|
|
30
|
+
// PTY path
|
|
51
31
|
const ptyParams = {
|
|
52
32
|
id,
|
|
53
33
|
type,
|
|
@@ -99,10 +79,10 @@ function list() {
|
|
|
99
79
|
idle: s.idle,
|
|
100
80
|
cwd: s.cwd,
|
|
101
81
|
customCommand: s.customCommand,
|
|
102
|
-
useTmux: s.
|
|
103
|
-
tmuxSessionName: s.
|
|
82
|
+
useTmux: s.useTmux,
|
|
83
|
+
tmuxSessionName: s.tmuxSessionName,
|
|
104
84
|
status: s.status,
|
|
105
|
-
needsBranchRename:
|
|
85
|
+
needsBranchRename: !!s.needsBranchRename,
|
|
106
86
|
}))
|
|
107
87
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
108
88
|
}
|
|
@@ -118,26 +98,21 @@ function kill(id) {
|
|
|
118
98
|
if (!session) {
|
|
119
99
|
throw new Error(`Session not found: ${id}`);
|
|
120
100
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
session.pty.kill('SIGTERM');
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// PTY may already be dead (e.g. disconnected sessions) — still delete from registry
|
|
127
|
-
}
|
|
128
|
-
if (session.tmuxSessionName) {
|
|
129
|
-
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
130
|
-
}
|
|
101
|
+
try {
|
|
102
|
+
session.pty.kill('SIGTERM');
|
|
131
103
|
}
|
|
132
|
-
|
|
133
|
-
|
|
104
|
+
catch {
|
|
105
|
+
// PTY may already be dead (e.g. disconnected sessions) — still delete from registry
|
|
106
|
+
}
|
|
107
|
+
if (session.tmuxSessionName) {
|
|
108
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
134
109
|
}
|
|
135
110
|
fireSessionEnd(id, session.repoPath, session.branchName);
|
|
136
111
|
sessions.delete(id);
|
|
137
112
|
}
|
|
138
113
|
function killAllTmuxSessions() {
|
|
139
114
|
for (const session of sessions.values()) {
|
|
140
|
-
if (session.
|
|
115
|
+
if (session.tmuxSessionName) {
|
|
141
116
|
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
142
117
|
}
|
|
143
118
|
}
|
|
@@ -147,25 +122,14 @@ function resize(id, cols, rows) {
|
|
|
147
122
|
if (!session) {
|
|
148
123
|
throw new Error(`Session not found: ${id}`);
|
|
149
124
|
}
|
|
150
|
-
|
|
151
|
-
session.pty.resize(cols, rows);
|
|
152
|
-
}
|
|
153
|
-
// SDK sessions don't support resize (no PTY)
|
|
125
|
+
session.pty.resize(cols, rows);
|
|
154
126
|
}
|
|
155
127
|
function write(id, data) {
|
|
156
128
|
const session = sessions.get(id);
|
|
157
129
|
if (!session) {
|
|
158
130
|
throw new Error(`Session not found: ${id}`);
|
|
159
131
|
}
|
|
160
|
-
|
|
161
|
-
session.pty.write(data);
|
|
162
|
-
}
|
|
163
|
-
else if (session.mode === 'sdk') {
|
|
164
|
-
sdkSendMessage(id, data);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
function handlePermission(id, requestId, approved) {
|
|
168
|
-
sdkHandlePermission(id, requestId, approved);
|
|
132
|
+
session.pty.write(data);
|
|
169
133
|
}
|
|
170
134
|
function findRepoSession(repoPath) {
|
|
171
135
|
return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
|
|
@@ -177,39 +141,32 @@ function serializeAll(configDir) {
|
|
|
177
141
|
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
178
142
|
fs.mkdirSync(scrollbackDirPath, { recursive: true });
|
|
179
143
|
const serializedPty = [];
|
|
180
|
-
const serializedSdk = [];
|
|
181
144
|
for (const session of sessions.values()) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
else if (session.mode === 'sdk') {
|
|
205
|
-
serializedSdk.push(serializeSdkSession(session));
|
|
206
|
-
}
|
|
145
|
+
// Write scrollback to disk
|
|
146
|
+
const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
|
|
147
|
+
fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
|
|
148
|
+
serializedPty.push({
|
|
149
|
+
id: session.id,
|
|
150
|
+
type: session.type,
|
|
151
|
+
agent: session.agent,
|
|
152
|
+
root: session.root,
|
|
153
|
+
repoName: session.repoName,
|
|
154
|
+
repoPath: session.repoPath,
|
|
155
|
+
worktreeName: session.worktreeName,
|
|
156
|
+
branchName: session.branchName,
|
|
157
|
+
displayName: session.displayName,
|
|
158
|
+
createdAt: session.createdAt,
|
|
159
|
+
lastActivity: session.lastActivity,
|
|
160
|
+
useTmux: session.useTmux,
|
|
161
|
+
tmuxSessionName: session.tmuxSessionName,
|
|
162
|
+
customCommand: session.customCommand,
|
|
163
|
+
cwd: session.cwd,
|
|
164
|
+
});
|
|
207
165
|
}
|
|
208
166
|
const pending = {
|
|
209
167
|
version: 1,
|
|
210
168
|
timestamp: new Date().toISOString(),
|
|
211
169
|
sessions: serializedPty,
|
|
212
|
-
sdkSessions: serializedSdk.length > 0 ? serializedSdk : undefined,
|
|
213
170
|
};
|
|
214
171
|
fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
|
|
215
172
|
}
|
|
@@ -309,18 +266,6 @@ async function restoreFromDisk(configDir) {
|
|
|
309
266
|
}
|
|
310
267
|
catch { /* ignore */ }
|
|
311
268
|
}
|
|
312
|
-
// Restore SDK sessions (as disconnected — they can't resume a live process)
|
|
313
|
-
if (pending.sdkSessions) {
|
|
314
|
-
for (const sdkData of pending.sdkSessions) {
|
|
315
|
-
try {
|
|
316
|
-
restoreSdkSession(sdkData, sessions);
|
|
317
|
-
restored++;
|
|
318
|
-
}
|
|
319
|
-
catch {
|
|
320
|
-
console.error(`Failed to restore SDK session ${sdkData.id} (${sdkData.displayName})`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
269
|
// Clean up
|
|
325
270
|
try {
|
|
326
271
|
fs.unlinkSync(pendingPath);
|
|
@@ -336,55 +281,66 @@ async function restoreFromDisk(configDir) {
|
|
|
336
281
|
function activeTmuxSessionNames() {
|
|
337
282
|
const names = new Set();
|
|
338
283
|
for (const session of sessions.values()) {
|
|
339
|
-
if (session.
|
|
284
|
+
if (session.tmuxSessionName)
|
|
340
285
|
names.add(session.tmuxSessionName);
|
|
341
286
|
}
|
|
342
287
|
return names;
|
|
343
288
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// Terminate sessions idle > 30 minutes
|
|
358
|
-
for (const session of sdkSessions) {
|
|
359
|
-
const lastActivity = new Date(session.lastActivity).getTime();
|
|
360
|
-
if (session.idle && (now - lastActivity) > SDK_MAX_IDLE_MS) {
|
|
361
|
-
console.log(`SDK idle sweep: terminating session ${session.id} (${session.displayName}) — idle for ${Math.round((now - lastActivity) / 60000)}min`);
|
|
362
|
-
try {
|
|
363
|
-
kill(session.id);
|
|
364
|
-
}
|
|
365
|
-
catch { /* already dead */ }
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// LRU eviction: if more than 5 idle SDK sessions remain, evict oldest
|
|
369
|
-
const idleSdkSessions = Array.from(sessions.values())
|
|
370
|
-
.filter((s) => s.mode === 'sdk' && s.idle)
|
|
371
|
-
.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity));
|
|
372
|
-
while (idleSdkSessions.length > SDK_MAX_IDLE_SESSIONS) {
|
|
373
|
-
const oldest = idleSdkSessions.shift();
|
|
374
|
-
console.log(`SDK idle sweep: evicting session ${oldest.id} (${oldest.displayName}) — LRU`);
|
|
375
|
-
try {
|
|
376
|
-
kill(oldest.id);
|
|
289
|
+
async function fetchMetaForSession(session) {
|
|
290
|
+
const repoPath = session.repoPath;
|
|
291
|
+
const branch = session.branchName;
|
|
292
|
+
let prNumber = null;
|
|
293
|
+
let additions = 0;
|
|
294
|
+
let deletions = 0;
|
|
295
|
+
if (branch) {
|
|
296
|
+
try {
|
|
297
|
+
const pr = await getPrForBranch(repoPath, branch);
|
|
298
|
+
if (pr) {
|
|
299
|
+
prNumber = pr.number;
|
|
300
|
+
additions = pr.additions;
|
|
301
|
+
deletions = pr.deletions;
|
|
377
302
|
}
|
|
378
|
-
catch { /* already dead */ }
|
|
379
303
|
}
|
|
380
|
-
|
|
304
|
+
catch { /* gh CLI unavailable */ }
|
|
305
|
+
}
|
|
306
|
+
// Fallback to working tree diff if no PR data
|
|
307
|
+
if (additions === 0 && deletions === 0) {
|
|
308
|
+
const diff = await getWorkingTreeDiff(repoPath);
|
|
309
|
+
additions = diff.additions;
|
|
310
|
+
deletions = diff.deletions;
|
|
311
|
+
}
|
|
312
|
+
return { prNumber, additions, deletions, fetchedAt: new Date().toISOString() };
|
|
381
313
|
}
|
|
382
|
-
function
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
314
|
+
async function getSessionMeta(id, refresh = false) {
|
|
315
|
+
if (!refresh && metaCache.has(id))
|
|
316
|
+
return metaCache.get(id);
|
|
317
|
+
const session = sessions.get(id);
|
|
318
|
+
if (!session)
|
|
319
|
+
return metaCache.get(id) ?? null;
|
|
320
|
+
const summary = list().find(s => s.id === id);
|
|
321
|
+
if (!summary)
|
|
322
|
+
return null;
|
|
323
|
+
const meta = await fetchMetaForSession(summary);
|
|
324
|
+
metaCache.set(id, meta);
|
|
325
|
+
return meta;
|
|
326
|
+
}
|
|
327
|
+
function getAllSessionMeta() {
|
|
328
|
+
const result = {};
|
|
329
|
+
for (const [key, meta] of metaCache) {
|
|
330
|
+
result[key] = meta;
|
|
386
331
|
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
// Populate cache for all active sessions (called on startup or refresh)
|
|
335
|
+
async function populateMetaCache() {
|
|
336
|
+
const allSessions = list();
|
|
337
|
+
await Promise.allSettled(allSessions.map(async (s) => {
|
|
338
|
+
if (!metaCache.has(s.id)) {
|
|
339
|
+
const meta = await fetchMetaForSession(s);
|
|
340
|
+
metaCache.set(s.id, meta);
|
|
341
|
+
}
|
|
342
|
+
}));
|
|
387
343
|
}
|
|
388
344
|
// Re-export pty-handler utilities for backward compatibility
|
|
389
345
|
export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
|
|
390
|
-
export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write,
|
|
346
|
+
export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onSessionEnd, fireSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
package/dist/server/types.js
CHANGED
package/dist/server/ws.js
CHANGED
|
@@ -2,15 +2,10 @@ import { WebSocketServer } from 'ws';
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import * as sessions from './sessions.js';
|
|
5
|
-
import { onSdkEvent, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission } from './sdk-handler.js';
|
|
6
5
|
import { writeMeta } from './config.js';
|
|
7
6
|
const execFileAsync = promisify(execFile);
|
|
8
|
-
const BACKPRESSURE_HIGH = 1024 * 1024; // 1MB
|
|
9
|
-
const BACKPRESSURE_LOW = 512 * 1024; // 512KB
|
|
10
7
|
const BRANCH_POLL_INTERVAL_MS = 3000;
|
|
11
8
|
const BRANCH_POLL_MAX_ATTEMPTS = 10;
|
|
12
|
-
const RENAME_CORE = `rename the current git branch using \`git branch -m <new-name>\` to a short, descriptive kebab-case name based on the task I'm asking about. Do not include any ticket numbers or prefixes.`;
|
|
13
|
-
const SDK_BRANCH_RENAME_INSTRUCTION = `Before responding to my message, first ${RENAME_CORE} After renaming, proceed with my request normally.\n\n`;
|
|
14
9
|
function startBranchWatcher(session, broadcastEvent, cfgPath) {
|
|
15
10
|
const originalBranch = session.branchName;
|
|
16
11
|
let attempts = 0;
|
|
@@ -88,7 +83,7 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
88
83
|
});
|
|
89
84
|
return;
|
|
90
85
|
}
|
|
91
|
-
// PTY
|
|
86
|
+
// PTY channel: /ws/:sessionId
|
|
92
87
|
const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
|
|
93
88
|
if (!match) {
|
|
94
89
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
@@ -112,15 +107,6 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
112
107
|
const session = sessionMap.get(ws);
|
|
113
108
|
if (!session)
|
|
114
109
|
return;
|
|
115
|
-
if (session.mode === 'sdk') {
|
|
116
|
-
handleSdkConnection(ws, session);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
// PTY mode — existing behavior
|
|
120
|
-
if (session.mode !== 'pty') {
|
|
121
|
-
ws.close(1008, 'Session mode does not support PTY streaming');
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
110
|
const ptySession = session;
|
|
125
111
|
let dataDisposable = null;
|
|
126
112
|
let exitDisposable = null;
|
|
@@ -190,84 +176,6 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
190
176
|
ptySession.onPtyReplacedCallbacks.splice(idx, 1);
|
|
191
177
|
});
|
|
192
178
|
});
|
|
193
|
-
function handleSdkConnection(ws, session) {
|
|
194
|
-
// Send session info
|
|
195
|
-
const sessionInfo = JSON.stringify({
|
|
196
|
-
type: 'session_info',
|
|
197
|
-
mode: 'sdk',
|
|
198
|
-
sessionId: session.id,
|
|
199
|
-
});
|
|
200
|
-
if (ws.readyState === ws.OPEN)
|
|
201
|
-
ws.send(sessionInfo);
|
|
202
|
-
// Replay stored events (send as-is — client expects raw SdkEvent shape)
|
|
203
|
-
for (const event of session.events) {
|
|
204
|
-
if (ws.readyState !== ws.OPEN)
|
|
205
|
-
break;
|
|
206
|
-
ws.send(JSON.stringify(event));
|
|
207
|
-
}
|
|
208
|
-
// Subscribe to live events with backpressure
|
|
209
|
-
let paused = false;
|
|
210
|
-
const unsubscribe = onSdkEvent(session.id, (event) => {
|
|
211
|
-
if (ws.readyState !== ws.OPEN)
|
|
212
|
-
return;
|
|
213
|
-
// Backpressure check
|
|
214
|
-
if (ws.bufferedAmount > BACKPRESSURE_HIGH) {
|
|
215
|
-
paused = true;
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
ws.send(JSON.stringify(event));
|
|
219
|
-
});
|
|
220
|
-
// Periodically check if we can resume
|
|
221
|
-
const backpressureInterval = setInterval(() => {
|
|
222
|
-
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW) {
|
|
223
|
-
paused = false;
|
|
224
|
-
}
|
|
225
|
-
}, 100);
|
|
226
|
-
// Handle incoming messages
|
|
227
|
-
ws.on('message', (msg) => {
|
|
228
|
-
const str = msg.toString();
|
|
229
|
-
try {
|
|
230
|
-
const parsed = JSON.parse(str);
|
|
231
|
-
if (parsed.type === 'message' && typeof parsed.text === 'string') {
|
|
232
|
-
if (parsed.text.length > 100_000)
|
|
233
|
-
return;
|
|
234
|
-
if (session.needsBranchRename) {
|
|
235
|
-
session.needsBranchRename = false;
|
|
236
|
-
sdkSendMessage(session.id, SDK_BRANCH_RENAME_INSTRUCTION + parsed.text);
|
|
237
|
-
if (configPath)
|
|
238
|
-
startBranchWatcher(session, broadcastEvent, configPath);
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
sdkSendMessage(session.id, parsed.text);
|
|
242
|
-
}
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if (parsed.type === 'permission' && typeof parsed.requestId === 'string' && typeof parsed.approved === 'boolean') {
|
|
246
|
-
sdkHandlePermission(session.id, parsed.requestId, parsed.approved);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
|
|
250
|
-
// TODO: wire up companion shell — currently open_companion message is unhandled server-side
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (parsed.type === 'open_companion') {
|
|
254
|
-
// TODO: spawn companion PTY in session CWD and relay via terminal_data/terminal_exit frames
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
catch (_) {
|
|
259
|
-
// Not JSON — ignore for SDK sessions
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
ws.on('close', () => {
|
|
263
|
-
unsubscribe();
|
|
264
|
-
clearInterval(backpressureInterval);
|
|
265
|
-
});
|
|
266
|
-
ws.on('error', () => {
|
|
267
|
-
unsubscribe();
|
|
268
|
-
clearInterval(backpressureInterval);
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
179
|
sessions.onIdleChange((sessionId, idle) => {
|
|
272
180
|
broadcastEvent('session-idle-changed', { sessionId, idle });
|
|
273
181
|
});
|