claude-remote-cli 3.9.5 → 3.10.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-CO9tRKXI.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BYv7-2w9.css">
14
+ <script type="module" crossorigin src="/assets/index-Dgf6cKGu.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BTOnhJQN.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
@@ -0,0 +1,136 @@
1
+ import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { Router } from 'express';
5
+ import { loadConfig } from './config.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const GIT_TIMEOUT_MS = 10_000;
8
+ const CACHE_TTL_MS = 60_000;
9
+ let cache = null;
10
+ /** Clears the branch linker cache (call when sessions are created or ended). */
11
+ export function invalidateBranchLinkerCache() {
12
+ cache = null;
13
+ }
14
+ /**
15
+ * Extracts all ticket IDs from a branch name.
16
+ * Returns an array of normalized ticket IDs (e.g. "PROJ-123", "GH-456").
17
+ */
18
+ function extractTicketIds(branchName) {
19
+ const ids = [];
20
+ // Jira/Linear style: PROJECT-123 (2+ uppercase letters, dash, digits)
21
+ // Skip "GH" prefix — that's our GitHub Issues namespace, handled separately below.
22
+ const jiraRegex = /([A-Z]{2,}-\d+)/gi;
23
+ let match;
24
+ while ((match = jiraRegex.exec(branchName)) !== null) {
25
+ if (match[1] && match[1].toUpperCase().split('-')[0] !== 'GH') {
26
+ ids.push(match[1].toUpperCase());
27
+ }
28
+ }
29
+ // GitHub Issues: gh-123 at word boundaries (start/end or preceded/followed by dash or slash)
30
+ const ghRegex = /(?:^|[-/])gh-(\d+)(?:[-/]|$)/gi;
31
+ while ((match = ghRegex.exec(branchName)) !== null) {
32
+ ids.push(`GH-${match[1]}`);
33
+ }
34
+ return ids;
35
+ }
36
+ /**
37
+ * Creates and returns an Express Router that handles all /branch-linker routes.
38
+ *
39
+ * Caller is responsible for mounting and applying auth middleware:
40
+ * app.use('/branch-linker', requireAuth, createBranchLinkerRouter({ configPath }));
41
+ */
42
+ export function createBranchLinkerRouter(deps) {
43
+ const { configPath } = deps;
44
+ const exec = deps.execAsync ?? execFileAsync;
45
+ const getActiveBranchNames = deps.getActiveBranchNames ?? (() => new Map());
46
+ const router = Router();
47
+ function getConfig() {
48
+ return loadConfig(configPath);
49
+ }
50
+ /** Core link-building logic, usable both from the HTTP handler and internal callers. */
51
+ async function fetchLinks() {
52
+ const config = getConfig();
53
+ const workspacePaths = config.workspaces ?? [];
54
+ if (workspacePaths.length === 0) {
55
+ return {};
56
+ }
57
+ const now = Date.now();
58
+ // Return cached result if still fresh
59
+ if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
60
+ return cache.links;
61
+ }
62
+ // Get active branch names per repo from sessions
63
+ const activeBranchNames = getActiveBranchNames();
64
+ // Fetch branches per workspace using Promise.allSettled (partial failures are non-fatal)
65
+ const results = await Promise.allSettled(workspacePaths.map(async (wsPath) => {
66
+ let stdout;
67
+ try {
68
+ ({ stdout } = await exec('git', ['branch', '--format=%(refname:short)'], { cwd: wsPath, timeout: GIT_TIMEOUT_MS }));
69
+ }
70
+ catch {
71
+ // Not a git repo or git not available — non-fatal
72
+ return [];
73
+ }
74
+ const repoName = path.basename(wsPath);
75
+ const activeInRepo = activeBranchNames.get(wsPath) ?? new Set();
76
+ const branchNames = stdout.split('\n').map((b) => b.trim()).filter(Boolean);
77
+ const links = [];
78
+ for (const branchName of branchNames) {
79
+ const ticketIds = extractTicketIds(branchName);
80
+ for (const ticketId of ticketIds) {
81
+ // Infer ticket source from ID pattern
82
+ let source;
83
+ if (ticketId.startsWith('GH-')) {
84
+ source = 'github';
85
+ }
86
+ else if (process.env.JIRA_API_TOKEN) {
87
+ source = 'jira';
88
+ }
89
+ else if (process.env.LINEAR_API_KEY) {
90
+ source = 'linear';
91
+ }
92
+ links.push({
93
+ ticketId,
94
+ link: {
95
+ repoPath: wsPath,
96
+ repoName,
97
+ branchName,
98
+ hasActiveSession: activeInRepo.has(branchName),
99
+ source,
100
+ },
101
+ });
102
+ }
103
+ }
104
+ return links;
105
+ }));
106
+ // Build the ticket -> BranchLink[] map
107
+ const linksMap = new Map();
108
+ for (const result of results) {
109
+ if (result.status === 'fulfilled') {
110
+ for (const { ticketId, link } of result.value) {
111
+ const existing = linksMap.get(ticketId);
112
+ if (existing) {
113
+ existing.push(link);
114
+ }
115
+ else {
116
+ linksMap.set(ticketId, [link]);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ // Convert Map to plain object for JSON serialization
122
+ const response = {};
123
+ for (const [ticketId, links] of linksMap) {
124
+ response[ticketId] = links;
125
+ }
126
+ // Update module-level cache
127
+ cache = { links: response, fetchedAt: now };
128
+ return response;
129
+ }
130
+ // GET /branch-linker/links — map of ticketId -> BranchLink[]
131
+ router.get('/links', async (_req, res) => {
132
+ const response = await fetchLinks();
133
+ res.json(response);
134
+ });
135
+ return Object.assign(router, { fetchLinks });
136
+ }
@@ -21,7 +21,37 @@ export function loadConfig(configPath) {
21
21
  }
22
22
  const raw = fs.readFileSync(configPath, 'utf8');
23
23
  const parsed = JSON.parse(raw);
24
- return { ...DEFAULTS, ...parsed };
24
+ const config = { ...DEFAULTS, ...parsed };
25
+ // Validate and clean workspaceGroups
26
+ if (config.workspaceGroups != null) {
27
+ const validPaths = new Set(config.workspaces ?? []);
28
+ const seenPaths = new Set();
29
+ const cleaned = {};
30
+ for (const [groupName, paths] of Object.entries(config.workspaceGroups)) {
31
+ if (!Array.isArray(paths)) {
32
+ console.warn(`workspaceGroups: group "${groupName}" value is not an array, skipping`);
33
+ continue;
34
+ }
35
+ const filteredPaths = [];
36
+ for (const p of paths) {
37
+ if (!validPaths.has(p)) {
38
+ console.warn(`workspaceGroups: path "${p}" in group "${groupName}" is not in workspaces[], skipping`);
39
+ continue;
40
+ }
41
+ if (seenPaths.has(p)) {
42
+ console.warn(`workspaceGroups: path "${p}" in group "${groupName}" is already assigned to another group, skipping`);
43
+ continue;
44
+ }
45
+ seenPaths.add(p);
46
+ filteredPaths.push(p);
47
+ }
48
+ if (filteredPaths.length > 0) {
49
+ cleaned[groupName] = filteredPaths;
50
+ }
51
+ }
52
+ config.workspaceGroups = cleaned;
53
+ }
54
+ return config;
25
55
  }
26
56
  export function saveConfig(configPath, config) {
27
57
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
@@ -20,7 +20,14 @@ import { listBranches, isBranchStale } from './git.js';
20
20
  import * as push from './push.js';
21
21
  import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
22
22
  import { createWorkspaceRouter } from './workspaces.js';
23
+ import { createOrgDashboardRouter } from './org-dashboard.js';
24
+ import { createIntegrationGitHubRouter } from './integration-github.js';
25
+ import { createBranchLinkerRouter, invalidateBranchLinkerCache } from './branch-linker.js';
23
26
  import { createHooksRouter } from './hooks.js';
27
+ import { createTicketTransitionsRouter } from './ticket-transitions.js';
28
+ import { createIntegrationJiraRouter } from './integration-jira.js';
29
+ import { createIntegrationLinearRouter } from './integration-linear.js';
30
+ import { startPolling, stopPolling } from './review-poller.js';
24
31
  import { MOUNTAIN_NAMES } from './types.js';
25
32
  import { semverLessThan } from './utils.js';
26
33
  const __filename = fileURLToPath(import.meta.url);
@@ -269,6 +276,45 @@ async function main() {
269
276
  // Mount workspace router
270
277
  const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
271
278
  app.use('/workspaces', requireAuth, workspaceRouter);
279
+ // Mount GitHub integration router
280
+ const integrationGitHubRouter = createIntegrationGitHubRouter({ configPath: CONFIG_PATH });
281
+ app.use('/integration-github', requireAuth, integrationGitHubRouter);
282
+ // Mount Jira integration router
283
+ const integrationJiraRouter = createIntegrationJiraRouter({ configPath: CONFIG_PATH });
284
+ app.use('/integration-jira', requireAuth, integrationJiraRouter);
285
+ // Mount Linear integration router
286
+ const integrationLinearRouter = createIntegrationLinearRouter({ configPath: CONFIG_PATH });
287
+ app.use('/integration-linear', requireAuth, integrationLinearRouter);
288
+ // Mount branch linker router
289
+ const branchLinkerRouter = createBranchLinkerRouter({
290
+ configPath: CONFIG_PATH,
291
+ getActiveBranchNames: () => {
292
+ const workspaces = config.workspaces ?? [];
293
+ const map = new Map();
294
+ for (const s of sessions.list()) {
295
+ if (!s.branchName)
296
+ continue;
297
+ // Normalize: match session repoPath to workspace root
298
+ // (worktree sessions store the worktree path, not workspace root)
299
+ const wsRoot = workspaces.find((ws) => s.repoPath.startsWith(ws)) ?? s.repoPath;
300
+ const existing = map.get(wsRoot);
301
+ if (existing) {
302
+ existing.add(s.branchName);
303
+ }
304
+ else {
305
+ map.set(wsRoot, new Set([s.branchName]));
306
+ }
307
+ }
308
+ return map;
309
+ },
310
+ });
311
+ app.use('/branch-linker', requireAuth, branchLinkerRouter);
312
+ // Mount ticket transitions router
313
+ const { router: ticketTransitionsRouter, transitionOnSessionCreate, checkPrTransitions } = createTicketTransitionsRouter({ configPath: CONFIG_PATH });
314
+ app.use('/ticket-transitions', requireAuth, ticketTransitionsRouter);
315
+ // Mount org dashboard router — use branchLinkerRouter.fetchLinks() directly (no loopback HTTP)
316
+ const orgDashboardRouter = createOrgDashboardRouter({ configPath: CONFIG_PATH, checkPrTransitions, getBranchLinks: () => branchLinkerRouter.fetchLinks() });
317
+ app.use('/org-dashboard', requireAuth, orgDashboardRouter);
272
318
  // Mount analytics router
273
319
  app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
274
320
  // Restore sessions from a previous update restart
@@ -278,6 +324,45 @@ async function main() {
278
324
  }
279
325
  // Populate session metadata cache in background (non-blocking)
280
326
  populateMetaCache().catch(() => { });
327
+ // Build shared deps for review poller
328
+ function buildPollerDeps() {
329
+ return {
330
+ configPath: CONFIG_PATH,
331
+ getWorkspacePaths: () => config.workspaces ?? [],
332
+ getWorkspaceSettings: (wsPath) => config.workspaceSettings?.[wsPath],
333
+ createSession: async (opts) => {
334
+ const resolved = resolveSessionSettings(config, opts.repoPath, {});
335
+ const roots = config.rootDirs || [];
336
+ const root = roots.find((r) => opts.repoPath.startsWith(r)) || '';
337
+ const repoName = opts.repoPath.split('/').filter(Boolean).pop() || 'session';
338
+ const worktreeName = opts.worktreePath.split('/').pop() || '';
339
+ const displayName = sessions.nextAgentName();
340
+ sessions.create({
341
+ type: 'worktree',
342
+ agent: resolved.agent,
343
+ repoName,
344
+ repoPath: opts.worktreePath,
345
+ cwd: opts.worktreePath,
346
+ root,
347
+ worktreeName,
348
+ branchName: opts.branchName,
349
+ displayName,
350
+ args: [...resolved.claudeArgs, ...(resolved.yolo ? AGENT_YOLO_ARGS[resolved.agent] : [])],
351
+ configPath: CONFIG_PATH,
352
+ useTmux: resolved.useTmux,
353
+ ...(opts.initialPrompt != null && { initialPrompt: opts.initialPrompt }),
354
+ });
355
+ },
356
+ broadcastEvent,
357
+ };
358
+ }
359
+ // Start review request poller if enabled
360
+ if (config.automations?.autoCheckoutReviewRequests) {
361
+ startPolling(buildPollerDeps());
362
+ }
363
+ // Invalidate branch linker cache on session lifecycle changes
364
+ sessions.onSessionCreate(() => { invalidateBranchLinkerCache(); });
365
+ sessions.onSessionEnd(() => { invalidateBranchLinkerCache(); });
281
366
  // Push notifications on session idle (skip when hooks already sent attention notification)
282
367
  sessions.onIdleChange((sessionId, idle) => {
283
368
  if (idle) {
@@ -509,6 +594,50 @@ async function main() {
509
594
  await execFileAsync('tmux', ['-V']);
510
595
  });
511
596
  boolConfigEndpoints('defaultNotifications', true);
597
+ // GET /config/automations — get automation settings
598
+ app.get('/config/automations', requireAuth, (_req, res) => {
599
+ res.json(config.automations ?? {});
600
+ });
601
+ // PATCH /config/automations — update automation settings and start/stop poller
602
+ app.patch('/config/automations', requireAuth, (req, res) => {
603
+ const body = req.body;
604
+ const prev = config.automations ?? {};
605
+ const next = { ...prev };
606
+ if (typeof body.autoCheckoutReviewRequests === 'boolean') {
607
+ next.autoCheckoutReviewRequests = body.autoCheckoutReviewRequests;
608
+ }
609
+ if (typeof body.autoReviewOnCheckout === 'boolean') {
610
+ next.autoReviewOnCheckout = body.autoReviewOnCheckout;
611
+ }
612
+ if (typeof body.pollIntervalMs === 'number' && body.pollIntervalMs >= 60000) {
613
+ next.pollIntervalMs = body.pollIntervalMs;
614
+ }
615
+ // Enforce: auto-review requires auto-checkout
616
+ if (!next.autoCheckoutReviewRequests) {
617
+ next.autoReviewOnCheckout = false;
618
+ }
619
+ config.automations = next;
620
+ try {
621
+ saveConfig(CONFIG_PATH, config);
622
+ }
623
+ catch (err) {
624
+ config.automations = prev;
625
+ console.error('[config] Failed to save automation settings:', err);
626
+ res.status(500).json({ error: 'Failed to save settings' });
627
+ return;
628
+ }
629
+ // Start or stop poller based on new setting
630
+ void stopPolling().then(() => {
631
+ if (next.autoCheckoutReviewRequests) {
632
+ startPolling(buildPollerDeps());
633
+ }
634
+ });
635
+ res.json(next);
636
+ });
637
+ // GET /config/workspace-groups — return workspace group configuration
638
+ app.get('/config/workspace-groups', requireAuth, (_req, res) => {
639
+ res.json({ groups: config.workspaceGroups ?? {} });
640
+ });
512
641
  // GET /push/vapid-key
513
642
  app.get('/push/vapid-key', requireAuth, (_req, res) => {
514
643
  const key = push.getVapidPublicKey();
@@ -606,7 +735,7 @@ async function main() {
606
735
  });
607
736
  // POST /sessions
608
737
  app.post('/sessions', requireAuth, async (req, res) => {
609
- const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, cols, rows, needsBranchRename, branchRenamePrompt } = req.body;
738
+ const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, cols, rows, needsBranchRename, branchRenamePrompt, ticketContext } = req.body;
610
739
  if (!repoPath) {
611
740
  res.status(400).json({ error: 'repoPath is required' });
612
741
  return;
@@ -617,6 +746,57 @@ async function main() {
617
746
  const resolved = resolveSessionSettings(config, repoPath, { agent, yolo, useTmux, claudeArgs });
618
747
  const resolvedAgent = resolved.agent;
619
748
  const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
749
+ let initialPrompt;
750
+ if (ticketContext && (typeof ticketContext.ticketId !== 'string' || typeof ticketContext.title !== 'string' || typeof ticketContext.url !== 'string')) {
751
+ res.status(400).json({ error: 'ticketContext requires string ticketId, title, and url' });
752
+ return;
753
+ }
754
+ if (ticketContext) {
755
+ // Validate source is a known integration
756
+ if (ticketContext.source !== 'github' && ticketContext.source !== 'jira' && ticketContext.source !== 'linear') {
757
+ res.status(400).json({ error: "ticketContext.source must be 'github', 'jira', or 'linear'" });
758
+ return;
759
+ }
760
+ // Validate repoPath is a configured workspace
761
+ const configuredWorkspaces = config.workspaces || [];
762
+ if (!configuredWorkspaces.includes(ticketContext.repoPath)) {
763
+ res.status(400).json({ error: 'ticketContext.repoPath is not a configured workspace' });
764
+ return;
765
+ }
766
+ // Validate integration is configured for the claimed source
767
+ if (ticketContext.source === 'jira') {
768
+ if (!process.env['JIRA_API_TOKEN'] || !process.env['JIRA_EMAIL'] || !process.env['JIRA_BASE_URL']) {
769
+ res.status(400).json({ error: 'Jira integration is not configured' });
770
+ return;
771
+ }
772
+ }
773
+ else if (ticketContext.source === 'linear') {
774
+ if (!process.env['LINEAR_API_KEY']) {
775
+ res.status(400).json({ error: 'Linear integration is not configured' });
776
+ return;
777
+ }
778
+ }
779
+ // Validate ticket ID format per source
780
+ if (ticketContext.source === 'github' && !/^GH-\d+$/.test(ticketContext.ticketId)) {
781
+ res.status(400).json({ error: 'ticketContext.ticketId for github must match GH-<number>' });
782
+ return;
783
+ }
784
+ if ((ticketContext.source === 'jira' || ticketContext.source === 'linear') && !/^[A-Z]+-\d+$/.test(ticketContext.ticketId)) {
785
+ res.status(400).json({ error: 'ticketContext.ticketId must match <PROJECT>-<number>' });
786
+ return;
787
+ }
788
+ }
789
+ if (ticketContext) {
790
+ // Use ticketContext.repoPath (workspace root) for settings lookup
791
+ const settings = config.workspaceSettings?.[ticketContext.repoPath];
792
+ const template = settings?.promptStartWork ??
793
+ 'You are working on ticket {ticketId}: {title}\n\nTicket URL: {ticketUrl}\n\nPlease start by understanding the issue and proposing an approach.';
794
+ initialPrompt = template
795
+ .replace(/\{ticketId\}/g, ticketContext.ticketId)
796
+ .replace(/\{title\}/g, ticketContext.title)
797
+ .replace(/\{ticketUrl\}/g, ticketContext.url)
798
+ .replace(/\{description\}/g, ticketContext.description ?? '');
799
+ }
620
800
  const baseArgs = [
621
801
  ...(resolved.claudeArgs),
622
802
  ...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
@@ -728,7 +908,13 @@ async function main() {
728
908
  useTmux: resolved.useTmux,
729
909
  ...(safeCols != null && { cols: safeCols }),
730
910
  ...(safeRows != null && { rows: safeRows }),
911
+ ...(initialPrompt != null && { initialPrompt }),
731
912
  });
913
+ if (ticketContext) {
914
+ transitionOnSessionCreate(ticketContext).catch((err) => {
915
+ console.error('[index] transition on session create failed:', err);
916
+ });
917
+ }
732
918
  res.status(201).json(repoSession);
733
919
  return;
734
920
  }
@@ -754,6 +940,7 @@ async function main() {
754
940
  useTmux: resolved.useTmux,
755
941
  ...(safeCols != null && { cols: safeCols }),
756
942
  ...(safeRows != null && { rows: safeRows }),
943
+ ...(initialPrompt != null && { initialPrompt }),
757
944
  });
758
945
  writeMeta(CONFIG_PATH, {
759
946
  worktreePath: sessionRepoPath,
@@ -761,6 +948,11 @@ async function main() {
761
948
  lastActivity: new Date().toISOString(),
762
949
  branchName: branchName || worktreeName,
763
950
  });
951
+ if (ticketContext) {
952
+ transitionOnSessionCreate(ticketContext).catch((err) => {
953
+ console.error('[index] transition on session create failed:', err);
954
+ });
955
+ }
764
956
  res.status(201).json(session);
765
957
  return;
766
958
  }
@@ -801,6 +993,7 @@ async function main() {
801
993
  ...(safeRows != null && { rows: safeRows }),
802
994
  needsBranchRename: isMountainName || (needsBranchRename ?? false),
803
995
  branchRenamePrompt: branchRenamePrompt ?? '',
996
+ ...(initialPrompt != null && { initialPrompt }),
804
997
  });
805
998
  if (!worktreePath) {
806
999
  writeMeta(CONFIG_PATH, {
@@ -810,6 +1003,11 @@ async function main() {
810
1003
  branchName: branchName || worktreeName,
811
1004
  });
812
1005
  }
1006
+ if (ticketContext) {
1007
+ transitionOnSessionCreate(ticketContext).catch((err) => {
1008
+ console.error('[index] transition on session create failed:', err);
1009
+ });
1010
+ }
813
1011
  res.status(201).json(session);
814
1012
  });
815
1013
  // POST /sessions/repo — start a session in the repo root (no worktree)
@@ -999,7 +1197,8 @@ async function main() {
999
1197
  catch {
1000
1198
  // tmux not installed or no sessions — ignore
1001
1199
  }
1002
- function gracefulShutdown() {
1200
+ async function gracefulShutdown() {
1201
+ await stopPolling();
1003
1202
  closeAnalytics();
1004
1203
  branchWatcher.close();
1005
1204
  server.close();
@@ -0,0 +1,117 @@
1
+ import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { Router } from 'express';
5
+ import { loadConfig } from './config.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const GH_TIMEOUT_MS = 10_000;
8
+ const CACHE_TTL_MS = 60_000;
9
+ /**
10
+ * Creates and returns an Express Router that handles all /integration-github routes.
11
+ *
12
+ * Caller is responsible for mounting and applying auth middleware:
13
+ * app.use('/integration-github', requireAuth, createIntegrationGitHubRouter({ configPath }));
14
+ */
15
+ export function createIntegrationGitHubRouter(deps) {
16
+ const { configPath } = deps;
17
+ const exec = deps.execAsync ?? execFileAsync;
18
+ const router = Router();
19
+ // Per-repo 60s in-memory cache
20
+ const repoCache = new Map();
21
+ function getConfig() {
22
+ return loadConfig(configPath);
23
+ }
24
+ // GET /integrations/github/issues — list open issues assigned to @me across all workspaces
25
+ router.get('/issues', async (_req, res) => {
26
+ const config = getConfig();
27
+ const workspacePaths = config.workspaces ?? [];
28
+ if (workspacePaths.length === 0) {
29
+ const response = { issues: [], error: 'no_workspaces' };
30
+ res.json(response);
31
+ return;
32
+ }
33
+ const now = Date.now();
34
+ // Fetch issues per repo using Promise.allSettled (partial failures are non-fatal)
35
+ const results = await Promise.allSettled(workspacePaths.map(async (wsPath) => {
36
+ // Return cached result if still fresh
37
+ const cached = repoCache.get(wsPath);
38
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
39
+ return cached.issues;
40
+ }
41
+ let stdout;
42
+ try {
43
+ ({ stdout } = await exec('gh', [
44
+ 'issue', 'list',
45
+ '--assignee', '@me',
46
+ '--state', 'open',
47
+ '--json', 'number,title,url,state,labels,assignees,createdAt,updatedAt',
48
+ '--limit', '50',
49
+ ], { cwd: wsPath, timeout: GH_TIMEOUT_MS }));
50
+ }
51
+ catch (err) {
52
+ const errCode = err.code;
53
+ if (errCode === 'ENOENT') {
54
+ throw Object.assign(new Error('gh_not_in_path'), { code: 'GH_NOT_IN_PATH' });
55
+ }
56
+ // Check for auth failure via stderr
57
+ const stderr = err.stderr ?? '';
58
+ if (stderr.includes('not logged') || stderr.includes('auth') || stderr.includes('authentication')) {
59
+ throw Object.assign(new Error('gh_not_authenticated'), { code: 'GH_NOT_AUTHENTICATED' });
60
+ }
61
+ // Not a github repo or other non-fatal error
62
+ return [];
63
+ }
64
+ let items;
65
+ try {
66
+ items = JSON.parse(stdout);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const repoName = path.basename(wsPath);
72
+ const issues = items.map((item) => ({
73
+ number: item.number,
74
+ title: item.title,
75
+ url: item.url,
76
+ state: item.state === 'OPEN' ? 'OPEN' : 'CLOSED',
77
+ labels: item.labels,
78
+ assignees: item.assignees,
79
+ createdAt: item.createdAt,
80
+ updatedAt: item.updatedAt,
81
+ repoName,
82
+ repoPath: wsPath,
83
+ }));
84
+ // Update per-repo cache
85
+ repoCache.set(wsPath, { issues, fetchedAt: now });
86
+ return issues;
87
+ }));
88
+ // Check if gh is not in path or not authenticated (any settled rejection with known codes)
89
+ for (const result of results) {
90
+ if (result.status === 'rejected') {
91
+ const err = result.reason;
92
+ if (err.code === 'GH_NOT_IN_PATH') {
93
+ const response = { issues: [], error: 'gh_not_in_path' };
94
+ res.json(response);
95
+ return;
96
+ }
97
+ if (err.code === 'GH_NOT_AUTHENTICATED') {
98
+ const response = { issues: [], error: 'gh_not_authenticated' };
99
+ res.json(response);
100
+ return;
101
+ }
102
+ }
103
+ }
104
+ // Merge all fulfilled results
105
+ const allIssues = [];
106
+ for (const result of results) {
107
+ if (result.status === 'fulfilled') {
108
+ allIssues.push(...result.value);
109
+ }
110
+ }
111
+ // Sort by updatedAt descending
112
+ allIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
113
+ const response = { issues: allIssues };
114
+ res.json(response);
115
+ });
116
+ return router;
117
+ }