claude-remote-cli 3.1.1 → 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-BNLfnaOa.css +32 -0
- package/dist/frontend/assets/index-ggOT9Hda.js +47 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/git.js +55 -2
- package/dist/server/index.js +20 -63
- package/dist/server/pty-handler.js +2 -0
- package/dist/server/push.js +1 -32
- package/dist/server/sessions.js +98 -133
- package/dist/server/types.js +1 -1
- package/dist/server/workspaces.js +8 -2
- package/dist/server/ws.js +4 -93
- package/dist/test/pr-state.test.js +69 -13
- package/package.json +1 -2
- package/dist/frontend/assets/index-BRH8jV0L.js +0 -47
- package/dist/frontend/assets/index-w5wJhB5f.css +0 -32
- 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
|
@@ -168,7 +168,7 @@ async function getPrForBranch(repoPath, branch, options = {}) {
|
|
|
168
168
|
'view',
|
|
169
169
|
branch,
|
|
170
170
|
'--json',
|
|
171
|
-
'number,title,url,state,headRefName,baseRefName,reviewDecision,isDraft',
|
|
171
|
+
'number,title,url,state,headRefName,baseRefName,reviewDecision,isDraft,additions,deletions,mergeable',
|
|
172
172
|
], { cwd: repoPath, timeout: 5000 }));
|
|
173
173
|
}
|
|
174
174
|
catch {
|
|
@@ -187,6 +187,10 @@ async function getPrForBranch(repoPath, branch, options = {}) {
|
|
|
187
187
|
baseRefName: data.baseRefName,
|
|
188
188
|
isDraft: data.isDraft,
|
|
189
189
|
reviewDecision: data.reviewDecision ?? null,
|
|
190
|
+
additions: data.additions ?? 0,
|
|
191
|
+
deletions: data.deletions ?? 0,
|
|
192
|
+
mergeable: data.mergeable ?? 'UNKNOWN',
|
|
193
|
+
unresolvedCommentCount: 0,
|
|
190
194
|
};
|
|
191
195
|
}
|
|
192
196
|
catch {
|
|
@@ -219,4 +223,53 @@ async function getCommitsAhead(repoPath, branch, baseBranch, options = {}) {
|
|
|
219
223
|
return 0;
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
|
-
|
|
226
|
+
async function getUnresolvedCommentCount(repoPath, prNumber, options = {}) {
|
|
227
|
+
const run = options.exec || execFileAsync;
|
|
228
|
+
try {
|
|
229
|
+
const { stdout: repoStdout } = await run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { cwd: repoPath, timeout: 5000 });
|
|
230
|
+
const nameWithOwner = repoStdout.trim();
|
|
231
|
+
if (!nameWithOwner)
|
|
232
|
+
return 0;
|
|
233
|
+
const [owner, repo] = nameWithOwner.split('/');
|
|
234
|
+
if (!owner || !repo)
|
|
235
|
+
return 0;
|
|
236
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
237
|
+
repository(owner: $owner, name: $repo) {
|
|
238
|
+
pullRequest(number: $number) {
|
|
239
|
+
reviewThreads(first: 100) {
|
|
240
|
+
nodes { isResolved }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}`;
|
|
245
|
+
const { stdout } = await run('gh', [
|
|
246
|
+
'api', 'graphql',
|
|
247
|
+
'-f', `query=${query}`,
|
|
248
|
+
'-f', `owner=${owner}`,
|
|
249
|
+
'-f', `repo=${repo}`,
|
|
250
|
+
'-F', `number=${prNumber}`,
|
|
251
|
+
], { cwd: repoPath, timeout: 10000 });
|
|
252
|
+
const result = JSON.parse(stdout);
|
|
253
|
+
const nodes = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
254
|
+
return nodes.filter((n) => !n.isResolved).length;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
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);
|
|
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS } from './types.js';
|
|
6
6
|
import { readMeta, writeMeta } from './config.js';
|
|
7
|
+
import { fireSessionEnd } from './sessions.js';
|
|
7
8
|
const IDLE_TIMEOUT_MS = 5000;
|
|
8
9
|
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
9
10
|
export function generateTmuxSessionName(displayName, id) {
|
|
@@ -184,6 +185,7 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks) {
|
|
|
184
185
|
if (configPath && worktreeName) {
|
|
185
186
|
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
186
187
|
}
|
|
188
|
+
fireSessionEnd(id, repoPath, session.branchName);
|
|
187
189
|
sessionsMap.delete(id);
|
|
188
190
|
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
189
191
|
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
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,41 +5,29 @@ 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) {
|
|
19
18
|
idleChangeCallbacks.push(cb);
|
|
20
19
|
}
|
|
20
|
+
const sessionEndCallbacks = [];
|
|
21
|
+
function onSessionEnd(cb) {
|
|
22
|
+
sessionEndCallbacks.push(cb);
|
|
23
|
+
}
|
|
24
|
+
function fireSessionEnd(sessionId, repoPath, branchName) {
|
|
25
|
+
for (const cb of sessionEndCallbacks)
|
|
26
|
+
cb(sessionId, repoPath, branchName);
|
|
27
|
+
}
|
|
21
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 }) {
|
|
22
29
|
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
23
|
-
//
|
|
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, needsBranchRename: false };
|
|
39
|
-
}
|
|
40
|
-
// SDK init failed — fall through to PTY
|
|
41
|
-
}
|
|
42
|
-
// PTY path: codex, terminal, custom command, or SDK fallback
|
|
30
|
+
// PTY path
|
|
43
31
|
const ptyParams = {
|
|
44
32
|
id,
|
|
45
33
|
type,
|
|
@@ -91,10 +79,10 @@ function list() {
|
|
|
91
79
|
idle: s.idle,
|
|
92
80
|
cwd: s.cwd,
|
|
93
81
|
customCommand: s.customCommand,
|
|
94
|
-
useTmux: s.
|
|
95
|
-
tmuxSessionName: s.
|
|
82
|
+
useTmux: s.useTmux,
|
|
83
|
+
tmuxSessionName: s.tmuxSessionName,
|
|
96
84
|
status: s.status,
|
|
97
|
-
needsBranchRename:
|
|
85
|
+
needsBranchRename: !!s.needsBranchRename,
|
|
98
86
|
}))
|
|
99
87
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
100
88
|
}
|
|
@@ -110,25 +98,21 @@ function kill(id) {
|
|
|
110
98
|
if (!session) {
|
|
111
99
|
throw new Error(`Session not found: ${id}`);
|
|
112
100
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
session.pty.kill('SIGTERM');
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
// PTY may already be dead (e.g. disconnected sessions) — still delete from registry
|
|
119
|
-
}
|
|
120
|
-
if (session.tmuxSessionName) {
|
|
121
|
-
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
122
|
-
}
|
|
101
|
+
try {
|
|
102
|
+
session.pty.kill('SIGTERM');
|
|
123
103
|
}
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
catch {
|
|
105
|
+
// PTY may already be dead (e.g. disconnected sessions) — still delete from registry
|
|
126
106
|
}
|
|
107
|
+
if (session.tmuxSessionName) {
|
|
108
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
109
|
+
}
|
|
110
|
+
fireSessionEnd(id, session.repoPath, session.branchName);
|
|
127
111
|
sessions.delete(id);
|
|
128
112
|
}
|
|
129
113
|
function killAllTmuxSessions() {
|
|
130
114
|
for (const session of sessions.values()) {
|
|
131
|
-
if (session.
|
|
115
|
+
if (session.tmuxSessionName) {
|
|
132
116
|
execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
|
|
133
117
|
}
|
|
134
118
|
}
|
|
@@ -138,25 +122,14 @@ function resize(id, cols, rows) {
|
|
|
138
122
|
if (!session) {
|
|
139
123
|
throw new Error(`Session not found: ${id}`);
|
|
140
124
|
}
|
|
141
|
-
|
|
142
|
-
session.pty.resize(cols, rows);
|
|
143
|
-
}
|
|
144
|
-
// SDK sessions don't support resize (no PTY)
|
|
125
|
+
session.pty.resize(cols, rows);
|
|
145
126
|
}
|
|
146
127
|
function write(id, data) {
|
|
147
128
|
const session = sessions.get(id);
|
|
148
129
|
if (!session) {
|
|
149
130
|
throw new Error(`Session not found: ${id}`);
|
|
150
131
|
}
|
|
151
|
-
|
|
152
|
-
session.pty.write(data);
|
|
153
|
-
}
|
|
154
|
-
else if (session.mode === 'sdk') {
|
|
155
|
-
sdkSendMessage(id, data);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
function handlePermission(id, requestId, approved) {
|
|
159
|
-
sdkHandlePermission(id, requestId, approved);
|
|
132
|
+
session.pty.write(data);
|
|
160
133
|
}
|
|
161
134
|
function findRepoSession(repoPath) {
|
|
162
135
|
return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
|
|
@@ -168,39 +141,32 @@ function serializeAll(configDir) {
|
|
|
168
141
|
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
169
142
|
fs.mkdirSync(scrollbackDirPath, { recursive: true });
|
|
170
143
|
const serializedPty = [];
|
|
171
|
-
const serializedSdk = [];
|
|
172
144
|
for (const session of sessions.values()) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
else if (session.mode === 'sdk') {
|
|
196
|
-
serializedSdk.push(serializeSdkSession(session));
|
|
197
|
-
}
|
|
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
|
+
});
|
|
198
165
|
}
|
|
199
166
|
const pending = {
|
|
200
167
|
version: 1,
|
|
201
168
|
timestamp: new Date().toISOString(),
|
|
202
169
|
sessions: serializedPty,
|
|
203
|
-
sdkSessions: serializedSdk.length > 0 ? serializedSdk : undefined,
|
|
204
170
|
};
|
|
205
171
|
fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
|
|
206
172
|
}
|
|
@@ -300,18 +266,6 @@ async function restoreFromDisk(configDir) {
|
|
|
300
266
|
}
|
|
301
267
|
catch { /* ignore */ }
|
|
302
268
|
}
|
|
303
|
-
// Restore SDK sessions (as disconnected — they can't resume a live process)
|
|
304
|
-
if (pending.sdkSessions) {
|
|
305
|
-
for (const sdkData of pending.sdkSessions) {
|
|
306
|
-
try {
|
|
307
|
-
restoreSdkSession(sdkData, sessions);
|
|
308
|
-
restored++;
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
console.error(`Failed to restore SDK session ${sdkData.id} (${sdkData.displayName})`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
269
|
// Clean up
|
|
316
270
|
try {
|
|
317
271
|
fs.unlinkSync(pendingPath);
|
|
@@ -327,55 +281,66 @@ async function restoreFromDisk(configDir) {
|
|
|
327
281
|
function activeTmuxSessionNames() {
|
|
328
282
|
const names = new Set();
|
|
329
283
|
for (const session of sessions.values()) {
|
|
330
|
-
if (session.
|
|
284
|
+
if (session.tmuxSessionName)
|
|
331
285
|
names.add(session.tmuxSessionName);
|
|
332
286
|
}
|
|
333
287
|
return names;
|
|
334
288
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
// Terminate sessions idle > 30 minutes
|
|
349
|
-
for (const session of sdkSessions) {
|
|
350
|
-
const lastActivity = new Date(session.lastActivity).getTime();
|
|
351
|
-
if (session.idle && (now - lastActivity) > SDK_MAX_IDLE_MS) {
|
|
352
|
-
console.log(`SDK idle sweep: terminating session ${session.id} (${session.displayName}) — idle for ${Math.round((now - lastActivity) / 60000)}min`);
|
|
353
|
-
try {
|
|
354
|
-
kill(session.id);
|
|
355
|
-
}
|
|
356
|
-
catch { /* already dead */ }
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// LRU eviction: if more than 5 idle SDK sessions remain, evict oldest
|
|
360
|
-
const idleSdkSessions = Array.from(sessions.values())
|
|
361
|
-
.filter((s) => s.mode === 'sdk' && s.idle)
|
|
362
|
-
.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity));
|
|
363
|
-
while (idleSdkSessions.length > SDK_MAX_IDLE_SESSIONS) {
|
|
364
|
-
const oldest = idleSdkSessions.shift();
|
|
365
|
-
console.log(`SDK idle sweep: evicting session ${oldest.id} (${oldest.displayName}) — LRU`);
|
|
366
|
-
try {
|
|
367
|
-
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;
|
|
368
302
|
}
|
|
369
|
-
catch { /* already dead */ }
|
|
370
303
|
}
|
|
371
|
-
|
|
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() };
|
|
372
313
|
}
|
|
373
|
-
function
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
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;
|
|
377
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
|
+
}));
|
|
378
343
|
}
|
|
379
344
|
// Re-export pty-handler utilities for backward compatibility
|
|
380
345
|
export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
|
|
381
|
-
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
|
@@ -5,7 +5,7 @@ import { execFile } from 'node:child_process';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { Router } from 'express';
|
|
7
7
|
import { loadConfig, saveConfig, getWorkspaceSettings, setWorkspaceSettings } from './config.js';
|
|
8
|
-
import { listBranches, getActivityFeed, getCiStatus, getPrForBranch, switchBranch, getCurrentBranch } from './git.js';
|
|
8
|
+
import { listBranches, getActivityFeed, getCiStatus, getPrForBranch, getUnresolvedCommentCount, switchBranch, getCurrentBranch } from './git.js';
|
|
9
9
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
11
|
const BROWSE_DENYLIST = new Set([
|
|
@@ -348,7 +348,13 @@ export function createWorkspaceRouter(deps) {
|
|
|
348
348
|
try {
|
|
349
349
|
const pr = await getPrForBranch(workspacePath, branch);
|
|
350
350
|
if (pr) {
|
|
351
|
-
|
|
351
|
+
if (pr.state === 'OPEN') {
|
|
352
|
+
const unresolvedCommentCount = await getUnresolvedCommentCount(workspacePath, pr.number);
|
|
353
|
+
res.json({ ...pr, unresolvedCommentCount });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
res.json({ ...pr, unresolvedCommentCount: 0 });
|
|
357
|
+
}
|
|
352
358
|
}
|
|
353
359
|
else {
|
|
354
360
|
res.status(404).json({ error: 'No PR found for branch' });
|