claude-remote-cli 3.9.4 → 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');
@@ -13,14 +13,21 @@ import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, populateMetaCache } from './sessions.js';
15
15
  import { setupWebSocket } from './ws.js';
16
- import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
16
+ import { WorktreeWatcher, BranchWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
17
17
  import { isInstalled as serviceIsInstalled } from './service.js';
18
18
  import { extensionForMime, setClipboardImage } from './clipboard.js';
19
19
  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);
@@ -235,6 +242,26 @@ async function main() {
235
242
  watcher.rebuild(config.workspaces || []);
236
243
  const server = http.createServer(app);
237
244
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
245
+ // Watch .git/HEAD files for branch changes and update active sessions
246
+ const branchWatcher = new BranchWatcher((cwdPath, newBranch) => {
247
+ for (const session of sessions.list()) {
248
+ if (session.repoPath === cwdPath || session.cwd === cwdPath) {
249
+ const raw = sessions.get(session.id);
250
+ if (raw) {
251
+ raw.branchName = newBranch;
252
+ broadcastEvent('session-renamed', {
253
+ sessionId: session.id,
254
+ branchName: newBranch,
255
+ displayName: raw.displayName,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ });
261
+ branchWatcher.rebuild(config.workspaces || []);
262
+ watcher.on('worktrees-changed', () => {
263
+ branchWatcher.rebuild(config.workspaces || []);
264
+ });
238
265
  // Configure session defaults for hooks injection
239
266
  sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
240
267
  // Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
@@ -249,6 +276,45 @@ async function main() {
249
276
  // Mount workspace router
250
277
  const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
251
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);
252
318
  // Mount analytics router
253
319
  app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
254
320
  // Restore sessions from a previous update restart
@@ -258,6 +324,45 @@ async function main() {
258
324
  }
259
325
  // Populate session metadata cache in background (non-blocking)
260
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(); });
261
366
  // Push notifications on session idle (skip when hooks already sent attention notification)
262
367
  sessions.onIdleChange((sessionId, idle) => {
263
368
  if (idle) {
@@ -301,9 +406,43 @@ async function main() {
301
406
  });
302
407
  res.json({ ok: true });
303
408
  });
304
- // GET /sessions
305
- app.get('/sessions', requireAuth, (_req, res) => {
306
- res.json(sessions.list());
409
+ // GET /sessions — enrich with live branch from git (rate-limited to avoid spawning git on every poll)
410
+ const branchRefreshCache = new Map(); // sessionId -> last refresh timestamp
411
+ const BRANCH_REFRESH_INTERVAL_MS = 10_000;
412
+ app.get('/sessions', requireAuth, async (_req, res) => {
413
+ const allSessions = sessions.list();
414
+ const now = Date.now();
415
+ // Prune cache entries for sessions that no longer exist
416
+ const activeIds = new Set(allSessions.map((s) => s.id));
417
+ for (const sessionId of branchRefreshCache.keys()) {
418
+ if (!activeIds.has(sessionId))
419
+ branchRefreshCache.delete(sessionId);
420
+ }
421
+ await Promise.all(allSessions.map(async (s) => {
422
+ if (s.type !== 'repo' && s.type !== 'worktree')
423
+ return;
424
+ if (!s.repoPath)
425
+ return;
426
+ const lastRefresh = branchRefreshCache.get(s.id) ?? 0;
427
+ if (now - lastRefresh < BRANCH_REFRESH_INTERVAL_MS)
428
+ return;
429
+ const cwd = s.type === 'repo' ? s.repoPath : s.cwd;
430
+ if (!cwd)
431
+ return;
432
+ branchRefreshCache.set(s.id, now);
433
+ try {
434
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
435
+ const liveBranch = stdout.trim();
436
+ if (liveBranch && liveBranch !== s.branchName) {
437
+ s.branchName = liveBranch;
438
+ const raw = sessions.get(s.id);
439
+ if (raw)
440
+ raw.branchName = liveBranch;
441
+ }
442
+ }
443
+ catch { /* non-fatal */ }
444
+ }));
445
+ res.json(allSessions);
307
446
  });
308
447
  // GET /repos — scan root dirs for repos
309
448
  app.get('/repos', requireAuth, async (_req, res) => {
@@ -455,6 +594,50 @@ async function main() {
455
594
  await execFileAsync('tmux', ['-V']);
456
595
  });
457
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
+ });
458
641
  // GET /push/vapid-key
459
642
  app.get('/push/vapid-key', requireAuth, (_req, res) => {
460
643
  const key = push.getVapidPublicKey();
@@ -552,7 +735,7 @@ async function main() {
552
735
  });
553
736
  // POST /sessions
554
737
  app.post('/sessions', requireAuth, async (req, res) => {
555
- 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;
556
739
  if (!repoPath) {
557
740
  res.status(400).json({ error: 'repoPath is required' });
558
741
  return;
@@ -563,6 +746,57 @@ async function main() {
563
746
  const resolved = resolveSessionSettings(config, repoPath, { agent, yolo, useTmux, claudeArgs });
564
747
  const resolvedAgent = resolved.agent;
565
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
+ }
566
800
  const baseArgs = [
567
801
  ...(resolved.claudeArgs),
568
802
  ...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
@@ -674,7 +908,13 @@ async function main() {
674
908
  useTmux: resolved.useTmux,
675
909
  ...(safeCols != null && { cols: safeCols }),
676
910
  ...(safeRows != null && { rows: safeRows }),
911
+ ...(initialPrompt != null && { initialPrompt }),
677
912
  });
913
+ if (ticketContext) {
914
+ transitionOnSessionCreate(ticketContext).catch((err) => {
915
+ console.error('[index] transition on session create failed:', err);
916
+ });
917
+ }
678
918
  res.status(201).json(repoSession);
679
919
  return;
680
920
  }
@@ -700,6 +940,7 @@ async function main() {
700
940
  useTmux: resolved.useTmux,
701
941
  ...(safeCols != null && { cols: safeCols }),
702
942
  ...(safeRows != null && { rows: safeRows }),
943
+ ...(initialPrompt != null && { initialPrompt }),
703
944
  });
704
945
  writeMeta(CONFIG_PATH, {
705
946
  worktreePath: sessionRepoPath,
@@ -707,6 +948,11 @@ async function main() {
707
948
  lastActivity: new Date().toISOString(),
708
949
  branchName: branchName || worktreeName,
709
950
  });
951
+ if (ticketContext) {
952
+ transitionOnSessionCreate(ticketContext).catch((err) => {
953
+ console.error('[index] transition on session create failed:', err);
954
+ });
955
+ }
710
956
  res.status(201).json(session);
711
957
  return;
712
958
  }
@@ -747,6 +993,7 @@ async function main() {
747
993
  ...(safeRows != null && { rows: safeRows }),
748
994
  needsBranchRename: isMountainName || (needsBranchRename ?? false),
749
995
  branchRenamePrompt: branchRenamePrompt ?? '',
996
+ ...(initialPrompt != null && { initialPrompt }),
750
997
  });
751
998
  if (!worktreePath) {
752
999
  writeMeta(CONFIG_PATH, {
@@ -756,6 +1003,11 @@ async function main() {
756
1003
  branchName: branchName || worktreeName,
757
1004
  });
758
1005
  }
1006
+ if (ticketContext) {
1007
+ transitionOnSessionCreate(ticketContext).catch((err) => {
1008
+ console.error('[index] transition on session create failed:', err);
1009
+ });
1010
+ }
759
1011
  res.status(201).json(session);
760
1012
  });
761
1013
  // POST /sessions/repo — start a session in the repo root (no worktree)
@@ -945,8 +1197,10 @@ async function main() {
945
1197
  catch {
946
1198
  // tmux not installed or no sessions — ignore
947
1199
  }
948
- function gracefulShutdown() {
1200
+ async function gracefulShutdown() {
1201
+ await stopPolling();
949
1202
  closeAnalytics();
1203
+ branchWatcher.close();
950
1204
  server.close();
951
1205
  // Serialize sessions to disk BEFORE killing them
952
1206
  const configDir = path.dirname(CONFIG_PATH);
@@ -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
+ }