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.
@@ -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-BRH8jV0L.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-w5wJhB5f.css">
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>
@@ -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
- export { listBranches, normalizeBranchNames, getActivityFeed, getCiStatus, getPrForBranch, switchBranch, getCommitsAhead, getCurrentBranch, };
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, };
@@ -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, startSdkIdleSweep, stopSdkIdleSweep } from './sessions.js';
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 + SDK)
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 }, () => { });
@@ -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, sdkEvent) {
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))
@@ -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 { createSdkSession, killSdkSession, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission, serializeSdkSession, restoreSdkSession } from './sdk-handler.js';
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
- // 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, 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.mode === 'pty' ? s.useTmux : false,
95
- tmuxSessionName: s.mode === 'pty' ? s.tmuxSessionName : '',
82
+ useTmux: s.useTmux,
83
+ tmuxSessionName: s.tmuxSessionName,
96
84
  status: s.status,
97
- needsBranchRename: s.mode === 'pty' ? !!s.needsBranchRename : false,
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
- if (session.mode === 'pty') {
114
- try {
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
- else if (session.mode === 'sdk') {
125
- killSdkSession(id);
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.mode === 'pty' && session.tmuxSessionName) {
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
- if (session.mode === 'pty') {
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
- if (session.mode === 'pty') {
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
- if (session.mode === 'pty') {
174
- // Write scrollback to disk
175
- const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
176
- fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
177
- serializedPty.push({
178
- id: session.id,
179
- type: session.type,
180
- agent: session.agent,
181
- root: session.root,
182
- repoName: session.repoName,
183
- repoPath: session.repoPath,
184
- worktreeName: session.worktreeName,
185
- branchName: session.branchName,
186
- displayName: session.displayName,
187
- createdAt: session.createdAt,
188
- lastActivity: session.lastActivity,
189
- useTmux: session.useTmux,
190
- tmuxSessionName: session.tmuxSessionName,
191
- customCommand: session.customCommand,
192
- cwd: session.cwd,
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.mode === 'pty' && session.tmuxSessionName)
284
+ if (session.tmuxSessionName)
331
285
  names.add(session.tmuxSessionName);
332
286
  }
333
287
  return names;
334
288
  }
335
- // SDK idle sweep: check every 60s, terminate SDK sessions idle > 30min, max 5 idle
336
- let sdkIdleSweepTimer = null;
337
- function startSdkIdleSweep() {
338
- if (sdkIdleSweepTimer)
339
- return;
340
- sdkIdleSweepTimer = setInterval(() => {
341
- const now = Date.now();
342
- const sdkSessions = [];
343
- for (const session of sessions.values()) {
344
- if (session.mode === 'sdk') {
345
- sdkSessions.push(session);
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
- }, SDK_IDLE_CHECK_INTERVAL_MS);
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 stopSdkIdleSweep() {
374
- if (sdkIdleSweepTimer) {
375
- clearInterval(sdkIdleSweepTimer);
376
- sdkIdleSweepTimer = null;
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, handlePermission, onIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, startSdkIdleSweep, stopSdkIdleSweep, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
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 };
@@ -1,4 +1,4 @@
1
- // Agent command records (shared by PTY and SDK handlers)
1
+ // Agent command records
2
2
  export const AGENT_COMMANDS = {
3
3
  claude: 'claude',
4
4
  codex: 'codex',
@@ -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
- res.json(pr);
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' });