@remits/remits-cli 0.1.108 → 0.1.110

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/index.js CHANGED
@@ -23,13 +23,12 @@ const SESSION_DIR = path.join(os.homedir(), '.remits-cli');
23
23
  const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
24
24
  const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
25
25
  const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
26
- const TMUX_ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'tmux-activity.log');
26
+ const ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'activity.log');
27
27
  const SKILL_SOURCE_FILE = path.join(__dirname, 'skills', 'remits-cli', 'SKILL.md');
28
28
  const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
29
29
  const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
30
30
  const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
31
31
  const WEBSOCKET_STATE_FILE = path.join(SESSION_DIR, 'websocket-state.json');
32
- const LISTENER_REGISTRATION_STATE_FILE = path.join(SESSION_DIR, 'listener-registration-state.json');
33
32
  const AUTO_UPDATE_LOCK_DIR = path.join(SESSION_DIR, 'auto-update.lock');
34
33
  const AUTO_UPDATE_DISABLED_VALUES = new Set(['0', 'false', 'no', 'off']);
35
34
  const REMITS_CLI_PACKAGE_NAME = '@remits/remits-cli';
@@ -48,8 +47,6 @@ const WEBSOCKET_CONNECTION_TIMEOUT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_
48
47
  const WEBSOCKET_WATCHDOG_GRACE_MS = Number(process.env.REMITS_CLI_WEBSOCKET_WATCHDOG_GRACE_MS || 30000);
49
48
  const SUPPORT_TICKET_DASHBOARD_REFRESH_MS = Number(process.env.REMITS_CLI_SUPPORT_TICKET_REFRESH_MS || 30000);
50
49
  const SUPPORT_TICKET_DASHBOARD_LIMIT = Number(process.env.REMITS_CLI_SUPPORT_TICKET_LIMIT || 20);
51
- const LISTENER_REGISTRATION_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_INTERVAL_MS || 5 * 60 * 1000);
52
- const LISTENER_REGISTRATION_MIN_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_MIN_INTERVAL_MS || 5 * 60 * 1000);
53
50
  const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
54
51
  '.git',
55
52
  '.gradle',
@@ -624,8 +621,8 @@ function appendGlobalActivityLog(scope, event, meta = {}, level = 'info') {
624
621
  const safeMeta = sanitizeForLog(meta);
625
622
  const payload = Object.keys(safeMeta || {}).length ? ' ' + JSON.stringify(safeMeta) : '';
626
623
  const line = new Date().toISOString() + ' [' + level + '] [' + scope + '] ' + event + payload + '\n';
627
- fs.appendFileSync(TMUX_ACTIVITY_LOG_FILE, line);
628
- return TMUX_ACTIVITY_LOG_FILE;
624
+ fs.appendFileSync(ACTIVITY_LOG_FILE, line);
625
+ return ACTIVITY_LOG_FILE;
629
626
  }
630
627
 
631
628
  function writeToolResponseFile(cwd, callId, responsePayload) {
@@ -1278,204 +1275,6 @@ function discoverAccountRepos() {
1278
1275
  };
1279
1276
  }
1280
1277
 
1281
- function listenerRegistrationRepoAccounts() {
1282
- return listAccountRepoEntries(readAccountRepoIndex())
1283
- .sort((a, b) => Number(a.accountId) - Number(b.accountId))
1284
- .map((entry) => ({
1285
- accountId: Number(entry.accountId),
1286
- name: entry.name || null,
1287
- type: entry.type || null,
1288
- platformAccountId: entry.platformAccountId || null,
1289
- routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
1290
- ? 'self_and_descendants'
1291
- : 'self',
1292
- source: entry.source || null,
1293
- updatedAt: entry.updatedAt || null
1294
- }));
1295
- }
1296
-
1297
- function readListenerRegistrationState() {
1298
- if (!fs.existsSync(LISTENER_REGISTRATION_STATE_FILE)) {
1299
- return {};
1300
- }
1301
- try {
1302
- const data = JSON.parse(fs.readFileSync(LISTENER_REGISTRATION_STATE_FILE, 'utf8'));
1303
- return data && typeof data === 'object' ? data : {};
1304
- } catch (_) {
1305
- return {};
1306
- }
1307
- }
1308
-
1309
- function writeListenerRegistrationState(data) {
1310
- ensureSessionDir();
1311
- atomicWriteFile(LISTENER_REGISTRATION_STATE_FILE, JSON.stringify(data || {}, null, 2));
1312
- }
1313
-
1314
- function listenerRegistrationStateKey(session) {
1315
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
1316
- const userId = session.user && session.user.id ? session.user.id : 'unknown';
1317
- return baseUrl + '|' + String(userId);
1318
- }
1319
-
1320
- function serviceRegistrationState() {
1321
- const state = readServiceState();
1322
- return {
1323
- running: isListenerRunning(),
1324
- pid: state && state.pid ? state.pid : (fs.existsSync(LISTENER_PID_FILE) ? fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim() : null),
1325
- startedAt: state && state.startedAt ? state.startedAt : runtimeState.service.startedAt,
1326
- dashboardUrl: state && state.dashboardUrl ? state.dashboardUrl : runtimeState.service.dashboardUrl
1327
- };
1328
- }
1329
-
1330
- function listenerRegistrationPayload(session, options = {}) {
1331
- const service = serviceRegistrationState();
1332
- const groupedSessions = Array.isArray(options.groupedSessions)
1333
- ? options.groupedSessions.filter((entry) => entry && entry.token)
1334
- : [];
1335
- const sessionDataModes = Array.from(new Set(
1336
- (groupedSessions.length ? groupedSessions : [session])
1337
- .map((entry) => normalizeDataMode(entry && entry.dataMode))
1338
- .filter(Boolean)
1339
- )).sort();
1340
- const sessionAccountIds = Array.from(new Set(
1341
- (groupedSessions.length ? groupedSessions : [session])
1342
- .map((entry) => Number(entry && entry.accountId))
1343
- .filter((accountId) => Number.isFinite(accountId) && accountId > 0)
1344
- )).sort((a, b) => a - b);
1345
- return {
1346
- client: 'remits-cli',
1347
- reason: options.reason || 'heartbeat',
1348
- baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
1349
- accountId: Number(session.accountId),
1350
- dataMode: normalizeDataMode(session.dataMode),
1351
- dataModes: sessionDataModes,
1352
- sessionAccountIds,
1353
- registrationSessionCount: groupedSessions.length || 1,
1354
- websocketTopic: session.websocketTopic || (session.user && session.user.uuid ? String(session.user.uuid).replace(/-/g, '') : null),
1355
- dashboardUrl: service.dashboardUrl || null,
1356
- agent: getPreferredAgent(),
1357
- service,
1358
- repoAccounts: listenerRegistrationRepoAccounts()
1359
- };
1360
- }
1361
-
1362
- function listenerRegistrationSignature(payload) {
1363
- return sha256(stableStringify({
1364
- baseUrl: payload.baseUrl,
1365
- accountId: payload.accountId,
1366
- dataMode: payload.dataMode,
1367
- dataModes: payload.dataModes || [],
1368
- sessionAccountIds: payload.sessionAccountIds || [],
1369
- websocketTopic: payload.websocketTopic,
1370
- dashboardUrl: payload.dashboardUrl,
1371
- agent: payload.agent,
1372
- serviceRunning: payload.service && payload.service.running,
1373
- repoAccounts: (payload.repoAccounts || []).map((entry) => ({
1374
- accountId: entry.accountId,
1375
- type: entry.type,
1376
- routingScope: entry.routingScope,
1377
- updatedAt: entry.updatedAt
1378
- }))
1379
- }));
1380
- }
1381
-
1382
- function shouldRegisterCliListener(session, payload, options = {}) {
1383
- if (options.force === true) {
1384
- return true;
1385
- }
1386
- const state = readListenerRegistrationState();
1387
- const key = listenerRegistrationStateKey(session);
1388
- const existing = state[key];
1389
- if (!existing) {
1390
- return true;
1391
- }
1392
- const lastRegisteredAtMs = Number(existing.lastRegisteredAtMs || 0);
1393
- if (!lastRegisteredAtMs || Date.now() - lastRegisteredAtMs >= LISTENER_REGISTRATION_MIN_INTERVAL_MS) {
1394
- return true;
1395
- }
1396
- return existing.signature !== listenerRegistrationSignature(payload);
1397
- }
1398
-
1399
- function rememberCliListenerRegistration(session, payload) {
1400
- const state = readListenerRegistrationState();
1401
- const key = listenerRegistrationStateKey(session);
1402
- state[key] = {
1403
- baseUrl: payload.baseUrl,
1404
- userId: session.user && session.user.id ? session.user.id : null,
1405
- signature: listenerRegistrationSignature(payload),
1406
- accountCount: Array.isArray(payload.repoAccounts) ? payload.repoAccounts.length : 0,
1407
- sessionAccountIds: payload.sessionAccountIds || [],
1408
- dataModes: payload.dataModes || [],
1409
- registrationSessionCount: payload.registrationSessionCount || 1,
1410
- lastRegisteredAt: new Date().toISOString(),
1411
- lastRegisteredAtMs: Date.now()
1412
- };
1413
- writeListenerRegistrationState(state);
1414
- }
1415
-
1416
- async function registerCliListenerSession(session, options = {}) {
1417
- if (!session || !session.token) {
1418
- return null;
1419
- }
1420
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
1421
- const payload = listenerRegistrationPayload(session, options);
1422
- if (!shouldRegisterCliListener(session, payload, options)) {
1423
- return { success: true, skipped: true, reason: 'recently_registered' };
1424
- }
1425
- const api = buildAxios(baseUrl, session.token, parsePositiveInt(options.timeoutMs, 5000));
1426
- const response = await api.post('/cli/register', payload);
1427
- rememberCliListenerRegistration(session, payload);
1428
- return response.data;
1429
- }
1430
-
1431
- async function bestEffortRegisterCliListeners(options = {}) {
1432
- const sessions = listSessionsFromStore(readAllSessions()).filter((session) => session && session.token);
1433
- if (!sessions.length) {
1434
- return [];
1435
- }
1436
- const groupedSessions = [];
1437
- for (const session of sessions) {
1438
- const key = listenerRegistrationStateKey(session);
1439
- let group = groupedSessions.find((entry) => entry.key === key);
1440
- if (!group) {
1441
- group = { key, session, sessions: [] };
1442
- groupedSessions.push(group);
1443
- }
1444
- group.sessions.push(session);
1445
- }
1446
-
1447
- const results = await Promise.all(groupedSessions.map(async (group) => {
1448
- try {
1449
- const data = await registerCliListenerSession(group.session, {
1450
- ...options,
1451
- groupedSessions: group.sessions
1452
- });
1453
- return {
1454
- success: true,
1455
- accountId: group.session.accountId,
1456
- baseUrl: group.session.baseUrl,
1457
- groupedAccountIds: group.sessions.map((session) => session.accountId),
1458
- data
1459
- };
1460
- } catch (err) {
1461
- appendGlobalActivityLog('listener', 'registration.failed', {
1462
- accountId: group.session.accountId,
1463
- baseUrl: normalizeBaseUrl(group.session.baseUrl || DEFAULT_BASE_URL),
1464
- groupedAccountIds: group.sessions.map((session) => session.accountId),
1465
- error: describeError(err)
1466
- }, 'error');
1467
- return {
1468
- success: false,
1469
- accountId: group.session.accountId,
1470
- baseUrl: group.session.baseUrl,
1471
- groupedAccountIds: group.sessions.map((session) => session.accountId),
1472
- error: describeError(err)
1473
- };
1474
- }
1475
- }));
1476
- return results;
1477
- }
1478
-
1479
1278
  function resolvePreferredAccountId(cwd, flags, session) {
1480
1279
  // Explicit --account-id wins over the repo's account-info.json so users can
1481
1280
  // target a child account (e.g. a customer) from a parent-account repo.
@@ -1839,7 +1638,16 @@ function describeError(err) {
1839
1638
  if (responseData.message) {
1840
1639
  parts.push(String(responseData.message));
1841
1640
  } else if (typeof responseData === 'string') {
1842
- parts.push(responseData);
1641
+ // A server that answers an unknown route with an HTML error page would otherwise put a whole
1642
+ // document into a log line or a dashboard panel. The status is the useful part; the page is not.
1643
+ const looksLikeHtml = /^\s*(<!doctype|<html)/i.test(responseData);
1644
+ if (looksLikeHtml) {
1645
+ const status = err.response.status ? 'HTTP ' + err.response.status : 'HTTP error';
1646
+ const title = (responseData.match(/<title[^>]*>([^<]{0,120})<\/title>/i) || [])[1];
1647
+ parts.push(status + (title ? ' - ' + title.trim() : ' (HTML error page)'));
1648
+ } else {
1649
+ parts.push(responseData.length > 500 ? responseData.slice(0, 500) + '...' : responseData);
1650
+ }
1843
1651
  }
1844
1652
  }
1845
1653
  if (err.message) {
@@ -1890,7 +1698,7 @@ function isLikelySandboxNetworkError(err) {
1890
1698
  function sandboxNetworkHint(err) {
1891
1699
  if (!isLikelySandboxNetworkError(err)) return '';
1892
1700
  const baseUrl = err && err.config && err.config.baseURL ? err.config.baseURL : DEFAULT_BASE_URL;
1893
- return ' Network access to ' + baseUrl + ' appears unavailable from the current sandbox. If remits-cli is being run by a local AI agent (for example Codex in tmux), retry the command with escalated permissions or outside the sandbox.';
1701
+ return ' Network access to ' + baseUrl + ' appears unavailable from the current sandbox. If remits-cli is being run by a local AI agent (for example Codex or Claude Code), retry the command with escalated permissions or outside the sandbox.';
1894
1702
  }
1895
1703
 
1896
1704
  function saveToolsSnapshot(cwd, data) {
@@ -2190,10 +1998,6 @@ async function authCommand(flags) {
2190
1998
  });
2191
1999
  console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
2192
2000
  console.log('Data mode:', session.dataMode);
2193
- try {
2194
- await bestEffortRegisterCliListeners({ cwd, reason: 'auth' });
2195
- } catch (_) { /* best-effort */ }
2196
-
2197
2001
  try {
2198
2002
  const branchName = currentBranch(cwd);
2199
2003
  const toolsData = await loggedPost(api, cwd, '/cli/tools', {
@@ -4237,6 +4041,10 @@ let supportTicketSnapshotCache = {
4237
4041
  fetchedAtMs: 0,
4238
4042
  value: null
4239
4043
  };
4044
+ let agentSnapshotCache = {
4045
+ fetchedAtMs: 0,
4046
+ value: null
4047
+ };
4240
4048
 
4241
4049
  function listenerSessionEntriesFromStore(store) {
4242
4050
  return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
@@ -4469,18 +4277,18 @@ function slugify(value) {
4469
4277
 
4470
4278
  function detectIssueSignals(repoEntries, globalFiles) {
4471
4279
  const issues = [];
4472
- const tmuxLog = (globalFiles || []).find((file) => file.label === 'tmux-activity.log (tail)');
4473
- if (tmuxLog && tmuxLog.content) {
4474
- const errorLines = tmuxLog.content
4280
+ const activityLog = (globalFiles || []).find((file) => file.label === 'activity.log (tail)');
4281
+ if (activityLog && activityLog.content) {
4282
+ const errorLines = activityLog.content
4475
4283
  .split('\n')
4476
4284
  .filter((line) => /\[error\]/i.test(line))
4477
4285
  .slice(-8);
4478
4286
  if (errorLines.length) {
4479
4287
  issues.push({
4480
- id: 'tmux-activity-errors',
4288
+ id: 'activity-errors',
4481
4289
  severity: 'warn',
4482
4290
  scope: 'global',
4483
- title: 'Recent service or tmux errors detected',
4291
+ title: 'Recent service errors detected',
4484
4292
  summary: 'The global remits-cli activity log contains recent error-level events.',
4485
4293
  evidence: errorLines,
4486
4294
  recommendation: 'Review the activity tail and export an escalation bundle if this reflects unexpected platform or dispatch behavior.'
@@ -4604,11 +4412,10 @@ function exportIssueBundle(snapshot) {
4604
4412
  service: snapshot.service,
4605
4413
  discovery: snapshot.discovery,
4606
4414
  issueCount: (snapshot.issues || []).length,
4607
- tmux: {
4608
- available: snapshot.tmux && snapshot.tmux.available,
4609
- sessionExists: snapshot.tmux && snapshot.tmux.sessionExists,
4610
- sessionName: snapshot.tmux && snapshot.tmux.sessionName,
4611
- livePaneCount: snapshot.tmux && snapshot.tmux.livePanes ? snapshot.tmux.livePanes.length : 0
4415
+ agents: {
4416
+ localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
4417
+ registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
4418
+ errors: (snapshot.agents && snapshot.agents.errors) || []
4612
4419
  },
4613
4420
  repos: (snapshot.repos || []).map((repo) => ({
4614
4421
  accountId: repo.accountId,
@@ -4624,10 +4431,9 @@ function exportIssueBundle(snapshot) {
4624
4431
  copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
4625
4432
  copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
4626
4433
  copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
4627
- copyFileIfExists(LISTENER_REGISTRATION_STATE_FILE, path.join(globalDir, 'listener-registration-state.json'));
4434
+ copyFileIfExists(AGENT_STATE_FILE, path.join(globalDir, 'agents.json'));
4628
4435
  copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
4629
- copyFileIfExists(DISPATCH_STATE_FILE, path.join(globalDir, 'dispatch-panes.json'));
4630
- copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
4436
+ copyFileIfExists(ACTIVITY_LOG_FILE, path.join(globalDir, 'activity.log'));
4631
4437
 
4632
4438
  const reposDir = path.join(bundleDir, 'repos');
4633
4439
  ensureDir(reposDir);
@@ -4666,10 +4472,9 @@ function buildGlobalFileSnapshot() {
4666
4472
  { label: 'config.json', path: CONFIG_FILE, mode: 'json' },
4667
4473
  { label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
4668
4474
  { label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
4669
- { label: 'listener-registration-state.json', path: LISTENER_REGISTRATION_STATE_FILE, mode: 'json' },
4670
- { label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
4475
+ { label: 'agents.json', path: AGENT_STATE_FILE, mode: 'json' },
4671
4476
  { label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
4672
- { label: 'tmux-activity.log (tail)', path: TMUX_ACTIVITY_LOG_FILE, mode: 'tail' }
4477
+ { label: 'activity.log (tail)', path: ACTIVITY_LOG_FILE, mode: 'tail' }
4673
4478
  ];
4674
4479
 
4675
4480
  return files.map((file) => {
@@ -4737,6 +4542,9 @@ function compactSupportTicketRow(row) {
4737
4542
  type: row.type || null,
4738
4543
  source: row.source || null,
4739
4544
  assignedTo: row.assignedTo || null,
4545
+ routedAgentId: row.routedAgentId || null,
4546
+ routedAgentLabel: row.routedAgentLabel || null,
4547
+ routedAt: row.routedAt || null,
4740
4548
  implementationAccountId: row.implementationAccountId || null,
4741
4549
  implementationAccountName: row.implementationAccountName || null,
4742
4550
  affectedComponent: row.affectedComponent || null,
@@ -4746,48 +4554,126 @@ function compactSupportTicketRow(row) {
4746
4554
  };
4747
4555
  }
4748
4556
 
4749
- function supportTicketQueueInput(session) {
4750
- return {
4751
- action: 'list',
4557
+ /**
4558
+ * The support-ticket queue for one authenticated session.
4559
+ *
4560
+ * Reads the PLATFORM's generic queue endpoint, deliberately not a front-stage tool. remits-cli has
4561
+ * to show the same ticket surface on every Remits platform, whatever helpdesk that platform
4562
+ * integrates with and whatever workflow it layers on top; product-specific views belong in that
4563
+ * product's own Embeddables.
4564
+ */
4565
+ async function fetchSupportTicketQueueForSession(session) {
4566
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4567
+ const api = buildAxios(baseUrl, session.token, 15000);
4568
+ const result = await api.post('/cli/tickets', {
4752
4569
  accountId: Number(session.accountId),
4753
4570
  includeChildren: true,
4754
- includeRoot: true,
4755
4571
  sortBy: 'triage',
4756
4572
  limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
4757
- };
4758
- }
4759
-
4760
- async function fetchSupportTicketQueueForSession(session) {
4761
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4762
- const api = buildAxios(baseUrl, session.token, 12000);
4763
- const response = await api.post('/cli/tool', {
4764
- token: session.token,
4765
- accountId: Number(session.accountId),
4766
- branchName: '',
4767
- dataMode: 'prod',
4768
- name: 'mcp_support_ticket_queue',
4769
- callId: 'dashboard-support-ticket-queue-' + crypto.randomUUID(),
4770
- input: supportTicketQueueInput(session)
4771
4573
  }).then((r) => r.data);
4772
4574
 
4773
- const result = response && response.result ? response.result : {};
4774
- const tickets = Array.isArray(result.tickets)
4575
+ const tickets = Array.isArray(result && result.tickets)
4775
4576
  ? result.tickets.map(compactSupportTicketRow).filter(Boolean)
4776
4577
  : [];
4777
4578
  return {
4778
- success: response && response.success !== false && response.toolSuccess !== false && result.success !== false,
4579
+ success: !!(result && result.success !== false),
4779
4580
  baseUrl,
4780
4581
  accountId: Number(session.accountId),
4781
4582
  userId: session.user && session.user.id ? session.user.id : null,
4782
- dataMode: result.dataMode || response.dataMode || 'prod',
4783
- totalCount: result.totalCount || tickets.length,
4784
- count: result.count || tickets.length,
4785
- counts: result.counts || {},
4786
- recommendedTicket: compactSupportTicketRow(result.recommendedTicket),
4583
+ dataMode: (result && result.dataMode) || session.dataMode || 'prod',
4584
+ totalCount: (result && result.totalCount) || tickets.length,
4585
+ count: (result && result.count) || tickets.length,
4586
+ counts: (result && result.counts) || {},
4587
+ recommendedTicket: compactSupportTicketRow(result && result.recommended),
4787
4588
  tickets,
4788
- searchedAccounts: result.searchedAccounts || [],
4789
- message: response.toolMessage || result.message || null
4589
+ searchedAccounts: (result && result.accounts) || [],
4590
+ message: (result && result.message) || null
4591
+ };
4592
+ }
4593
+
4594
+ /**
4595
+ * Who is available to work tickets, from the platform's point of view plus this machine's.
4596
+ *
4597
+ * `registered` is authoritative (it is what routing sees); `local` is the sessions this machine
4598
+ * believes it started. Showing both is the point: a local session missing from `registered` is
4599
+ * exactly the "my agent is not getting tickets" symptom, and the two lists side by side name it.
4600
+ */
4601
+ /**
4602
+ * Route a ticket at an agent from the dashboard, or clear its routing when agentId is empty.
4603
+ *
4604
+ * Goes through the same platform endpoint an operator Embeddable would use, so "route this ticket"
4605
+ * has ONE implementation and one access check whichever surface asked for it.
4606
+ */
4607
+ async function routeTicketFromDashboard({ ticketId, accountId, agentId }) {
4608
+ const numericAccountId = Number(accountId);
4609
+ if (!ticketId || !Number.isFinite(numericAccountId)) {
4610
+ throw new Error('Routing needs a ticketId and an accountId.');
4611
+ }
4612
+ const session = listSessionsFromStore(readAllSessions())
4613
+ .find((entry) => entry && entry.token && Number(entry.accountId) === numericAccountId) ||
4614
+ listSessionsFromStore(readAllSessions()).find((entry) => entry && entry.token);
4615
+ if (!session) {
4616
+ throw new Error('No authenticated session is available. Run: remits-cli auth');
4617
+ }
4618
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4619
+ const result = await buildAxios(baseUrl, session.token, 15000)
4620
+ .post('/cli/ticketRoute', {
4621
+ ticketId: String(ticketId),
4622
+ accountId: numericAccountId,
4623
+ agentId: agentId || null,
4624
+ dataMode: session.dataMode
4625
+ }).then((r) => r.data);
4626
+ appendGlobalActivityLog('ticket', 'routed', {
4627
+ ticketId, accountId: numericAccountId, agentId: agentId || null, success: result && result.success
4628
+ });
4629
+ if (result && result.success === false) {
4630
+ throw new Error(result.message || 'Routing was refused.');
4631
+ }
4632
+ return result;
4633
+ }
4634
+
4635
+ async function collectAgentSnapshot() {
4636
+ const nowMs = Date.now();
4637
+ if (agentSnapshotCache.value && nowMs - agentSnapshotCache.fetchedAtMs < SUPPORT_TICKET_DASHBOARD_REFRESH_MS) {
4638
+ return agentSnapshotCache.value;
4639
+ }
4640
+
4641
+ const sessions = listSessionsFromStore(readAllSessions())
4642
+ .filter((session) => session && session.token && session.accountId);
4643
+ const seen = new Set();
4644
+ const lookups = sessions.filter((session) => {
4645
+ const key = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL) + '|' + String(session.accountId);
4646
+ if (seen.has(key)) return false;
4647
+ seen.add(key);
4648
+ return true;
4649
+ }).slice(0, 12);
4650
+
4651
+ const byAgentId = {};
4652
+ const errors = [];
4653
+ await Promise.all(lookups.map(async (session) => {
4654
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4655
+ try {
4656
+ const result = await buildAxios(baseUrl, session.token, 12000)
4657
+ .post('/cli/agents', { accountId: Number(session.accountId), dataMode: session.dataMode })
4658
+ .then((r) => r.data);
4659
+ for (const agent of (result && result.agents) || []) {
4660
+ if (agent && agent.agentId) byAgentId[agent.agentId] = agent;
4661
+ }
4662
+ } catch (err) {
4663
+ errors.push({ baseUrl, accountId: Number(session.accountId), message: describeError(err) });
4664
+ }
4665
+ }));
4666
+
4667
+ const value = {
4668
+ fetchedAt: new Date(nowMs).toISOString(),
4669
+ refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4670
+ registered: Object.values(byAgentId)
4671
+ .sort((a, b) => Number(b.lastSeenAtMs || 0) - Number(a.lastSeenAtMs || 0)),
4672
+ local: listLocalAgents(),
4673
+ errors
4790
4674
  };
4675
+ agentSnapshotCache = { fetchedAtMs: nowMs, value };
4676
+ return value;
4791
4677
  }
4792
4678
 
4793
4679
  async function collectSupportTicketSnapshot() {
@@ -4845,6 +4731,13 @@ async function collectSupportTicketSnapshot() {
4845
4731
  refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4846
4732
  queueCount: queues.length,
4847
4733
  totalCount: queues.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0),
4734
+ counts: queues.reduce((totals, queue) => {
4735
+ const counts = queue.counts || {};
4736
+ totals.routed = Number(totals.routed || 0) + Number(counts.routed || 0);
4737
+ totals.unrouted = Number(totals.unrouted || 0) + Number(counts.unrouted || 0);
4738
+ totals.assigned = Number(totals.assigned || 0) + Number(counts.assigned || 0);
4739
+ return totals;
4740
+ }, {}),
4848
4741
  tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
4849
4742
  queues,
4850
4743
  errors
@@ -4865,29 +4758,6 @@ async function collectDashboardSnapshot() {
4865
4758
  const globalFiles = buildGlobalFileSnapshot();
4866
4759
  const issues = detectIssueSignals(repoEntries, globalFiles);
4867
4760
 
4868
- const paneState = fs.existsSync(DISPATCH_STATE_FILE) ? readJsonFilePretty(DISPATCH_STATE_FILE) : null;
4869
- const livePanes = [];
4870
- try {
4871
- if (isTmuxAvailable() && tmuxSessionExists()) {
4872
- const output = execSync(
4873
- 'tmux list-panes -t ' + shellQuote(TMUX_SESSION_NAME) + ' -F ' + shellQuote('#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_current_path}\t#{pane_dead}'),
4874
- { stdio: ['ignore', 'pipe', 'ignore'] }
4875
- ).toString();
4876
- for (const line of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
4877
- const parts = line.split('\t');
4878
- livePanes.push({
4879
- session: parts[0] || null,
4880
- window: parts[1] || null,
4881
- paneId: parts[2] || null,
4882
- currentPath: parts[3] || null,
4883
- dead: parts[4] === '1'
4884
- });
4885
- }
4886
- }
4887
- } catch (_) {
4888
- // Dashboard should remain readable even if tmux inspection fails.
4889
- }
4890
-
4891
4761
  return {
4892
4762
  generatedAt: new Date().toISOString(),
4893
4763
  service: {
@@ -4899,14 +4769,7 @@ async function collectDashboardSnapshot() {
4899
4769
  websocket: {
4900
4770
  connections: runtimeState.websocket
4901
4771
  },
4902
- tmux: {
4903
- available: isTmuxAvailable(),
4904
- terminalLaunchSupported: canLaunchTerminalForTmux(),
4905
- sessionName: TMUX_SESSION_NAME,
4906
- sessionExists: tmuxSessionExists(),
4907
- trackedPanes: paneState,
4908
- livePanes
4909
- },
4772
+ agents: await collectAgentSnapshot(),
4910
4773
  discovery: runtimeState.discovery,
4911
4774
  globalFiles,
4912
4775
  repos: repoEntries,
@@ -4952,9 +4815,7 @@ function renderDashboardHtml(snapshot) {
4952
4815
  '<button class="action-btn" data-action="export-issues">Export Issue Bundle</button>' +
4953
4816
  '<button class="action-btn" data-action="rescan-repos">Rescan Account Repos</button>' +
4954
4817
  '<button class="action-btn" data-action="reconnect-websockets">Reconnect Websockets</button>' +
4955
- '<button class="action-btn" data-action="open-tmux">Open Tmux Terminal</button>' +
4956
- '<button class="action-btn" data-action="start-tmux">Ensure Tmux Session</button>' +
4957
- '<button class="action-btn" data-action="stop-tmux">Kill Tmux Session</button>' +
4818
+ '<button class="action-btn" data-action="refresh-agents">Refresh Agents</button>' +
4958
4819
  '</div>' +
4959
4820
  '</section>' +
4960
4821
  '<div class="section-grid">' +
@@ -4967,6 +4828,9 @@ function renderDashboardHtml(snapshot) {
4967
4828
  '<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
4968
4829
  '</section>' +
4969
4830
  '</div>' +
4831
+ '<div class="section-grid">' +
4832
+ '<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
4833
+ '</div>' +
4970
4834
  '<div class="section-grid">' +
4971
4835
  '<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
4972
4836
  '</div>' +
@@ -4975,7 +4839,7 @@ function renderDashboardHtml(snapshot) {
4975
4839
  '</div>' +
4976
4840
  '<div class="section-grid">' +
4977
4841
  '<section class="panel"><h2>Websocket Connections</h2><div id="websocket-section" class="accordion"></div></section>' +
4978
- '<section class="panel"><h2>Tmux</h2><div id="tmux-section" class="accordion"></div></section>' +
4842
+ '<section class="panel"><h2>Activity Log</h2><div id="activity-section" class="accordion"></div></section>' +
4979
4843
  '</div>' +
4980
4844
  '<div class="section-grid">' +
4981
4845
  '<section class="panel"><h2>Global Files</h2><div id="global-files" class="accordion"></div></section>' +
@@ -5000,36 +4864,39 @@ function renderDashboardHtml(snapshot) {
5000
4864
  'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
5001
4865
  'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
5002
4866
  'function detailsBlock(key,title,meta,bodyHtml,openByDefault){return \'<details class="item" data-key="\'+escapeHtml(key)+\'"\'+(openByDefault?" open":"")+\'><summary><div class="summary-main"><div class="summary-text"><span class="summary-title">\'+escapeHtml(title)+\'</span><span class="summary-meta">\'+meta+\'</span></div></div><span class="summary-caret">›</span></summary><div class="item-body">\'+bodyHtml+\'</div></details>\';}' +
5003
- 'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const tmuxHealthy=state.tmux&&state.tmux.available&&state.tmux.sessionExists;const cards=[' +
4867
+ 'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const agentState=state.agents||{};const agentList=agentState.registered||[];const idleAgents=agentList.filter(function(a){return (a.state||"idle")==="idle";}).length;const cards=[' +
5004
4868
  '{title:"Service",value:state.service&&state.service.running?"Running":"Stopped",label:"Daemon status",tone:statusTone(state.service&&state.service.running),sub:(state.service&&state.service.dashboardUrl?state.service.dashboardUrl:"No dashboard URL yet")},' +
5005
4869
  '{title:"Websockets",value:String(connectedCount)+"/"+String(wsValues.length||0),label:"Connected topics groups",tone:errorCount? "bad" : (warnCount? "warn" : "ok"),sub: errorCount? String(errorCount)+" unhealthy connection(s)" : (warnCount? String(warnCount)+" connecting" : "All connections healthy")},' +
5006
4870
  '{title:"Tickets",value:String(ticketTotal),label:"Open support queue",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":"Fetched "+String(ticketState.queueCount||0)+" queue(s)"},' +
5007
- '{title:"Tmux",value:tmuxHealthy?"Ready":"Attention",label:"Dispatch workspace",tone:tmuxHealthy?"ok":(state.tmux&&state.tmux.available?"warn":"bad"),sub:state.tmux&&state.tmux.available?(state.tmux.sessionExists?String((state.tmux.livePanes||[]).length)+" live pane(s)":"tmux installed, session missing"):"tmux not installed"},' +
4871
+ '{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent register"},' +
5008
4872
  '{title:"Issues",value:String(issueCount),label:"Detected escalation signals",tone:criticalIssueCount?"bad":(issueCount?"warn":"ok"),sub:issueCount?(criticalIssueCount?String(criticalIssueCount)+" critical signal(s)":"Review before continuing"):"No issue signals detected"},' +
5009
4873
  '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
5010
4874
  '];document.getElementById("status-grid").innerHTML=cards.map(function(card){return \'<section class="status-card \'+card.tone+\'"><div class="status-head"><span class="status-dot"></span><div><div class="status-label">\'+escapeHtml(card.title)+\'</div><div class="status-value">\'+escapeHtml(card.value)+\'</div></div></div><div class="status-sub"><strong>\'+escapeHtml(card.label)+\':</strong> \'+escapeHtml(card.sub)+\'</div></section>\';}).join("");}' +
5011
- 'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const tracked=state.tmux&&state.tmux.trackedPanes?Object.keys(JSON.parse(state.tmux.trackedPanes||"{}")).length:0;document.getElementById("summary-grid").innerHTML=[' +
4875
+ 'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const ticketCounts=(state.supportTickets||{}).counts||{};document.getElementById("summary-grid").innerHTML=[' +
5012
4876
  'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
5013
4877
  'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
5014
4878
  'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
5015
4879
  'summaryItem("Connections",String(ws.length)),' +
5016
4880
  'summaryItem("Topics",String(topics)),' +
5017
4881
  'summaryItem("Open Tickets",String(state.supportTickets&&state.supportTickets.totalCount||0)),' +
5018
- 'summaryItem("Tracked Panes",String(tracked))' +
4882
+ 'summaryItem("Available Agents",String(agentList.length)),' +
4883
+ 'summaryItem("Routed Tickets",String(ticketCounts.routed||0))' +
5019
4884
  '].join("");}' +
5020
4885
  'function renderLastAction(state){const target=document.getElementById("last-action");if(!state.lastAction){target.className="empty";target.textContent="No dashboard actions have been triggered yet.";return;}target.className="summary-item";target.innerHTML=\'<span class="k">Last Action</span><span class="v">\'+escapeHtml(state.lastAction.action||"unknown")+\'</span><div class="muted" style="margin-top:8px;">\'+escapeHtml(formatTime(state.lastAction.at))+\'</div>\'+(state.lastAction.bundlePath?\'<div class="muted" style="margin-top:8px;word-break:break-word;"><strong>Bundle:</strong> \'+escapeHtml(state.lastAction.bundlePath)+\'</div>\':"");}' +
5021
4886
  'function renderIssues(state){const target=document.getElementById("issues-section");const issues=state.issues||[];if(!issues.length){target.innerHTML=\'<div class="empty">No automatic issue signals are currently detected. If something still feels wrong, you can still click "Export Issue Bundle" to prepare a package for a Remits system admin.</div>\';return;}target.innerHTML=issues.map(function(issue,index){const tone=issue.severity==="bad"?"bad":"warn";const meta=badge(issue.severity==="bad"?"critical":"warning",tone)+\' <span class="muted">\'+escapeHtml(issue.scope+(issue.repoAccountId?(" account "+issue.repoAccountId):""))+\'</span>\';const evidence=(issue.evidence||[]).map(function(line){return "<li>"+escapeHtml(line)+"</li>";}).join("");const body=\'<div class="stack"><div class="muted">\'+escapeHtml(issue.summary||"")+\'</div><div class="mini-card"><strong>Recommendation</strong>\'+escapeHtml(issue.recommendation||"") + \'</div><div><strong style="display:block;margin-bottom:8px;">Evidence</strong><ul style="margin:0;padding-left:18px;display:grid;gap:8px;">\'+(evidence||"<li>No evidence lines captured.</li>")+\'</ul></div></div>\';return detailsBlock("issue:"+issue.id,(index+1)+". "+issue.title,meta,body,index===0);}).join("");}' +
5022
- 'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Total Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Queues</strong>\'+escapeHtml(String(support.queueCount||0))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}const recs=queues.map(function(queue){return queue.recommendedTicket?Object.assign({queueAccountId:queue.accountId,baseUrl:queue.baseUrl},queue.recommendedTicket):null;}).filter(Boolean);if(recs.length){const recBody=recs.map(function(ticket,index){return supportTicketDetails(ticket,"support-rec:"+index);}).join("");html+=detailsBlock("support:recommended","Recommended next tickets",badge(String(recs.length)+" queue(s)","ok"),recBody,true);}if(tickets.length){html+=tickets.map(function(ticket,index){const meta=badge(ticket.priority||"priority","warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(ticket,"support-ticket:"+index),index===0&&!recs.length);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned by the current queues.</div>\';}target.innerHTML=html;}' +
5023
- 'function supportTicketDetails(ticket,key){const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Assigned</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Activity</strong>\'+escapeHtml(ticket.activityLabel||formatTime(ticket.activityAt||ticket.updatedAt))+\'</div></div><div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
4887
+ 'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];const counts=support.counts||{};let html=\'<div class="mini-grid"><div class="mini-card"><strong>Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Routed To An Agent</strong>\'+escapeHtml(String(counts.routed||0))+\'</div><div class="mini-card"><strong>Waiting</strong>\'+escapeHtml(String(counts.unrouted==null?Math.max(0,(support.totalCount||0)-(counts.routed||0)):counts.unrouted))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}if(tickets.length){html+=tickets.map(function(ticket,index){const routed=!!ticket.routedAgentId;const meta=badge(ticket.priority||"priority",ticket.priority==="critical"||ticket.priority==="high"?"bad":"warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' \'+badge(routed?("routed: "+(ticket.routedAgentLabel||ticket.routedAgentId)):"unrouted",routed?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(state,ticket,"support-ticket:"+index),index===0);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned.</div>\';}target.innerHTML=html;}' +
4888
+ 'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
5024
4889
  'function renderWebsockets(state){const entries=Object.entries((state.websocket&&state.websocket.connections)||{});const target=document.getElementById("websocket-section");if(!entries.length){target.innerHTML=\'<div class="empty">No websocket connections are registered yet.</div>\';return;}target.innerHTML=entries.map(function(pair){const baseUrl=pair[0];const info=pair[1]||{};const topicEntries=Object.entries(info.topics||{});const recentMessages=Array.isArray(info.recentMessages)?info.recentMessages:[];const tone=info.status==="connected"?"ok":(info.status==="connecting"?"warn":"bad");const summaryMeta=badge(info.status||"unknown",tone)+\' <span class="muted">\' + escapeHtml(String(topicEntries.length)) + \' topic(s)</span>\' + \' <span class="muted">\' + escapeHtml(String(recentMessages.length)) + \' recent message(s)</span>\';const topicBody=topicEntries.length?topicEntries.map(function(topicPair){const topic=topicPair[0];const topicInfo=topicPair[1]||{};const mini=\'<div class="mini-grid"><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((topicInfo.accountIds||[]).join(", ")||"n/a")+\'</div><div class="mini-card"><strong>Subscribed</strong>\'+escapeHtml(formatTime(topicInfo.subscribedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(topicInfo.lastMessageAt))+\'</div></div>\';return detailsBlock("ws-topic:"+baseUrl+":"+topic,topic,"topic subscription",mini,false);}).join(""):\'<div class="empty">No topic subscriptions recorded.</div>\';const messageBody=recentMessages.length?recentMessages.map(function(message,index){const messageMeta=badge(message.duplicate?"duplicate":"handled",message.duplicate?"warn":"ok")+\' <span class="muted">\'+escapeHtml(message.source||"live")+\'</span>\' + \' <span class="muted">\'+escapeHtml(message.type||"unknown-type")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Message ID</strong>\'+escapeHtml(message.messageId||"n/a")+\'</div><div class="mini-card"><strong>Timestamp</strong>\'+escapeHtml(message.timestamp?String(message.timestamp):"n/a")+\'</div><div class="mini-card"><strong>Received</strong>\'+escapeHtml(formatTime(message.receivedAt))+\'</div><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((message.accountIds||[]).join(", ")||"n/a")+\'</div></div>\'+codeBlock("ws-msg:"+baseUrl+":"+(message.messageId||index),JSON.stringify(message.payload||{},null,2)) + \'</div>\';return detailsBlock("ws-message:"+baseUrl+":"+(message.messageId||index),(message.type||"message")+" "+(message.messageId||""),messageMeta,body,false);}).join(""):\'<div class="empty">No recent websocket messages have been recorded yet.</div>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Status</strong>\'+escapeHtml(info.status||"unknown")+\'</div><div class="mini-card"><strong>Connected</strong>\'+escapeHtml(formatTime(info.connectedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(info.lastMessageAt))+\'</div><div class="mini-card"><strong>Last Error</strong>\'+escapeHtml(info.lastError||"none")+\'</div></div><div class="stack">\'+detailsBlock("ws-recent:"+baseUrl,"Recent messages","latest live and missed-message traffic",messageBody,false)+topicBody+\'</div></div>\';return detailsBlock("ws:"+baseUrl,baseUrl,summaryMeta,body,entries.length===1);}).join("");}' +
5025
- 'function renderTmux(state){const target=document.getElementById("tmux-section");const panes=state.tmux&&state.tmux.livePanes||[];const trackedText=state.tmux&&state.tmux.trackedPanes?state.tmux.trackedPanes:"{}";const launchSupported=state.tmux&&state.tmux.terminalLaunchSupported;const attachCommand="tmux attach -t "+(state.tmux&&state.tmux.sessionName||"remits-listener");const health=\'<div class="mini-grid"><div class="mini-card"><strong>tmux Installed</strong>\'+escapeHtml(String(state.tmux&&state.tmux.available))+\'</div><div class="mini-card"><strong>Session Exists</strong>\'+escapeHtml(String(state.tmux&&state.tmux.sessionExists))+\'</div><div class="mini-card"><strong>Session Name</strong>\'+escapeHtml(state.tmux&&state.tmux.sessionName||"n/a")+\'</div><div class="mini-card"><strong>Live Panes</strong>\'+escapeHtml(String(panes.length))+\'</div><div class="mini-card"><strong>Open from Dashboard</strong>\'+escapeHtml(launchSupported?"supported":"not supported")+\'</div></div><div class="muted" style="margin-bottom:14px;">\'+escapeHtml(launchSupported?"Use the Open Tmux Terminal button to bring Terminal forward and attach automatically.":"Dashboard-to-terminal launch is not supported on this platform. Run the attach command manually.")+\'</div>\';const quickAttach=detailsBlock("tmux:attach","Quick attach command","manual fallback",codeBlock("tmux-attach-cmd",attachCommand),false);const paneSection=detailsBlock("tmux:panes","Live panes",escapeHtml(String(panes.length))+" pane(s)",codeBlock("tmux-live-panes",JSON.stringify(panes,null,2),"log"),false);const trackedSection=detailsBlock("tmux:tracked","Tracked pane routing","ticket/task to pane mapping",codeBlock("tmux-tracked",trackedText),false);const logFile=(state.globalFiles||[]).find(function(file){return file.label==="tmux-activity.log (tail)";});const logSection=detailsBlock("tmux:activity","Activity tail","stream of tmux and websocket events",codeBlock("tmux-log",logFile&&logFile.content||"[missing]","log"),true);target.innerHTML=health+quickAttach+paneSection+trackedSection+logSection;const openButton=document.querySelector(\'[data-action="open-tmux"]\');if(openButton){openButton.disabled=!launchSupported||!(state.tmux&&state.tmux.available);}}' +
5026
- 'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="tmux-activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
4890
+ 'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
4891
+ 'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
4892
+ 'function renderActivity(state){const target=document.getElementById("activity-section");const logFile=(state.globalFiles||[]).find(function(file){return file.label==="activity.log (tail)";});target.innerHTML=detailsBlock("activity:log","Activity tail","service lifecycle, websocket, and agent events",codeBlock("activity-log",logFile&&logFile.content||"[missing]","log"),true);}' +
4893
+ 'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
5027
4894
  'function renderRepos(state){const target=document.getElementById("repo-section");const repos=state.repos||[];if(!repos.length){target.innerHTML=\'<div class="empty">No indexed account repos were found. Use "Rescan Account Repos" to rebuild the index.</div>\';return;}target.innerHTML=repos.map(function(repo){const tone=repo.exists?"ok":"warn";const meta=badge(repo.exists?"available":"missing",tone)+\' <span class="muted">type: \'+escapeHtml(repo.type||"n/a")+\'</span>\';const files=(repo.files||[]).map(function(file){const fileMeta=badge(file.exists?"present":"missing",file.exists?"ok":"warn")+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("repo:"+repo.accountId+":"+file.label,file.content,file.label.indexOf("session log")>=0?"log":"")+\'</div>\';return detailsBlock("repo-file:"+repo.accountId+":"+file.label,file.label,fileMeta,body,false);}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Directory</strong>\'+escapeHtml(repo.directory||"")+\'</div><div class="mini-card"><strong>Source</strong>\'+escapeHtml(repo.source||"n/a")+\'</div><div class="mini-card"><strong>Platform Account</strong>\'+escapeHtml(String(repo.platformAccountId||"n/a"))+\'</div></div><div class="repo-files">\'+files+\'</div></div>\';return detailsBlock("repo:"+repo.accountId,(repo.name||"Unknown")+" ("+repo.accountId+")",meta,body,false);}).join("");}' +
5028
- 'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderTmux(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
4895
+ 'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderAgents(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
5029
4896
  'async function fetchState(){const res=await fetch("/api/state",{cache:"no-store"});if(!res.ok)throw new Error("State refresh failed with "+res.status);return res.json();}' +
5030
4897
  'function showToast(message){const toast=document.getElementById("toast");toast.textContent=message;toast.classList.add("show");clearTimeout(showToast._timer);showToast._timer=setTimeout(function(){toast.classList.remove("show");},2400);}' +
5031
- 'async function triggerAction(action,button){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:"action="+encodeURIComponent(action)});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
5032
- 'document.querySelectorAll("[data-action]").forEach(function(button){button.addEventListener("click",function(){triggerAction(button.getAttribute("data-action"),button);});});' +
4898
+ 'async function triggerAction(action,button,extra){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{let body="action="+encodeURIComponent(action);Object.keys(extra||{}).forEach(function(k){body+="&"+encodeURIComponent(k)+"="+encodeURIComponent(extra[k]==null?"":extra[k]);});const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
4899
+ 'document.addEventListener("click",function(event){const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value};}triggerAction(action,button,extra);});' +
5033
4900
  'renderAll(dashboardState);' +
5034
4901
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
5035
4902
  '})();' +
@@ -5116,15 +4983,19 @@ function startDashboardServer(preferredPort) {
5116
4983
  } else if (action === 'rescan-repos') {
5117
4984
  recordDiscoverySummary(discoverAccountRepos());
5118
4985
  supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5119
- await bestEffortRegisterCliListeners({ reason: 'dashboard.rescan' });
5120
- } else if (action === 'open-tmux') {
5121
- openTmuxSessionForUser();
5122
- } else if (action === 'start-tmux') {
5123
- ensureTmuxSession(os.homedir());
5124
- } else if (action === 'stop-tmux') {
5125
- killTmuxSession();
4986
+ } else if (action === 'refresh-agents') {
4987
+ agentSnapshotCache = { fetchedAtMs: 0, value: null };
4988
+ supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5126
4989
  } else if (action === 'reconnect-websockets') {
5127
4990
  await reconnectManagedWebsockets();
4991
+ } else if (action === 'route-ticket') {
4992
+ actionResult = await routeTicketFromDashboard({
4993
+ ticketId: params.get('ticketId'),
4994
+ accountId: params.get('accountId'),
4995
+ agentId: params.get('agentId')
4996
+ });
4997
+ supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
4998
+ agentSnapshotCache = { fetchedAtMs: 0, value: null };
5128
4999
  }
5129
5000
  if (wantsJson) {
5130
5001
  res.statusCode = 200;
@@ -5186,518 +5057,110 @@ function startDashboardServer(preferredPort) {
5186
5057
  });
5187
5058
  }
5188
5059
 
5189
- // --- Agent CLI dispatch via tmux ---
5190
- const TMUX_SESSION_NAME = 'remits-listener';
5191
- const DISPATCH_STATE_FILE = path.join(SESSION_DIR, 'dispatch-panes.json');
5192
-
5193
- // In-memory map of support-ticket routing key -> tmux pane ID (persisted for crash recovery)
5194
- let paneMap = {};
5195
-
5196
- function dispatchTicketId(message) {
5197
- if (!message || typeof message !== 'object') return null;
5198
- if (message.ticketId !== undefined && message.ticketId !== null && String(message.ticketId).trim()) {
5199
- return String(message.ticketId).trim();
5060
+ function publishMissedMessageRequest(client, baseUrl, userId) {
5061
+ if (!userId) {
5062
+ return;
5200
5063
  }
5201
- return null;
5064
+ const since = lastMessageTimestampForScope(baseUrl, userId);
5065
+ client.publish({
5066
+ destination: '/app/missed-messages',
5067
+ body: JSON.stringify({
5068
+ userId: String(userId),
5069
+ since
5070
+ })
5071
+ });
5072
+ appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
5073
+ baseUrl,
5074
+ userId,
5075
+ since
5076
+ });
5202
5077
  }
5203
5078
 
5204
- function dispatchRoutingKey(message) {
5205
- const ticketId = dispatchTicketId(message);
5206
- if (ticketId) return 'ticket:' + ticketId;
5207
- if (message && message.taskId !== undefined && message.taskId !== null && String(message.taskId).trim()) {
5208
- return 'task:' + String(message.taskId).trim();
5079
+ function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
5080
+ if (!messageId) {
5081
+ return;
5209
5082
  }
5210
- return null;
5083
+ const primaryUserId = userIds.find(Boolean);
5084
+ const body = primaryUserId
5085
+ ? { messageId, userId: String(primaryUserId) }
5086
+ : { messageId };
5087
+ client.publish({
5088
+ destination: '/app/ack',
5089
+ body: JSON.stringify(body)
5090
+ });
5211
5091
  }
5212
5092
 
5213
- function loadPaneMap() {
5214
- if (fs.existsSync(DISPATCH_STATE_FILE)) {
5215
- try {
5216
- paneMap = JSON.parse(fs.readFileSync(DISPATCH_STATE_FILE, 'utf8'));
5217
- } catch (_) {
5218
- paneMap = {};
5219
- }
5093
+ function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
5094
+ const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
5095
+ const messageId = data.messageId ? String(data.messageId) : null;
5096
+ const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
5097
+
5098
+ recordWebsocketMessage(baseUrl, topic, data, {
5099
+ source,
5100
+ duplicate,
5101
+ accountIds
5102
+ });
5103
+
5104
+ if (messageId) {
5105
+ acknowledgeWebsocketMessage(client, messageId, userIds);
5220
5106
  }
5221
- }
5222
5107
 
5223
- function savePaneMap() {
5224
- ensureSessionDir();
5225
- atomicWriteFile(DISPATCH_STATE_FILE, JSON.stringify(paneMap, null, 2));
5226
- }
5108
+ updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
5109
+ runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
5110
+ if (runtimeState.websocket[baseUrl].topics[topic]) {
5111
+ runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
5112
+ }
5227
5113
 
5228
- function isTmuxAvailable() {
5229
- try {
5230
- execSync('which tmux', { stdio: 'ignore' });
5231
- return true;
5232
- } catch (_) {
5233
- return false;
5114
+ if (duplicate) {
5115
+ appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
5116
+ baseUrl,
5117
+ topic,
5118
+ messageId,
5119
+ source
5120
+ });
5121
+ updateRuntimeServiceState();
5122
+ return;
5234
5123
  }
5235
- }
5236
5124
 
5237
- function tmuxSessionExists() {
5238
- try {
5239
- execSync('tmux has-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
5240
- return true;
5241
- } catch (_) {
5242
- return false;
5125
+ if (messageId) {
5126
+ rememberProcessedMessageId(messageId);
5243
5127
  }
5244
- }
5245
5128
 
5246
- function tmuxPaneIsAlive(paneId) {
5247
- try {
5248
- // Check both that the pane exists AND that it's not dead
5249
- const output = execSync(
5250
- 'tmux list-panes -a -F ' + shellQuote('#{pane_id} #{pane_dead}'),
5251
- { stdio: ['ignore', 'pipe', 'ignore'] }
5252
- ).toString();
5253
- const lines = output.split('\n').map((l) => l.trim()).filter(Boolean);
5254
- for (const line of lines) {
5255
- const [id, dead] = line.split(' ');
5256
- if (id === paneId) {
5257
- return dead !== '1';
5258
- }
5129
+ if (data.type === 'TestSuite') {
5130
+ const payload = data.payload || {};
5131
+ const payloadTaskId = payload.taskId || null;
5132
+ const currentTestTaskId = runtimeState.currentTestTaskId || null;
5133
+ if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
5134
+ updateRuntimeServiceState();
5135
+ return;
5259
5136
  }
5260
- return false; // pane not found at all
5261
- } catch (_) {
5262
- return false;
5137
+ if (payload.tests || payload.name || payload.total != null) {
5138
+ const acctId = payload.accountId || accountIds[0];
5139
+ console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
5140
+ }
5141
+ } else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
5142
+ // A ticket nudge. The hand-off itself is durable on the ticket and is collected by whichever
5143
+ // agent session it was routed to, so nothing is dispatched from here: this only refreshes what
5144
+ // the dashboard shows a human, and records the event for triage.
5145
+ const payload = data.payload || data;
5146
+ const nudgeAccountId = Number(payload.accountId || accountIds[0]) || accountIds[0];
5147
+ console.log('[ws][ticket][account:' + nudgeAccountId + ']', JSON.stringify(payload));
5148
+ appendGlobalActivityLog('ticket', 'nudge.received', {
5149
+ baseUrl,
5150
+ topic,
5151
+ accountId: nudgeAccountId,
5152
+ ticketId: payload.ticketId || null,
5153
+ agentId: payload.agentId || null,
5154
+ messageType: data.type,
5155
+ source,
5156
+ messageId,
5157
+ message: summarizeDispatchMessage(payload)
5158
+ });
5159
+ supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5160
+ agentSnapshotCache = { fetchedAtMs: 0, value: null };
5263
5161
  }
5264
- }
5265
-
5266
- function killPane(paneId) {
5267
- try {
5268
- execSync('tmux kill-pane -t ' + shellQuote(paneId), { stdio: 'ignore' });
5269
- appendGlobalActivityLog('tmux', 'pane.killed', { paneId });
5270
- } catch (_) {}
5271
- }
5272
5162
 
5273
- function killTmuxSession() {
5274
- if (!tmuxSessionExists()) return;
5275
- try {
5276
- execSync('tmux kill-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
5277
- appendGlobalActivityLog('tmux', 'session.killed', { sessionName: TMUX_SESSION_NAME });
5278
- } catch (_) {}
5279
- }
5280
-
5281
- function configureTmuxSession() {
5282
- const commands = [
5283
- 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' mouse on',
5284
- 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' history-limit 100000',
5285
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' remain-on-exit on',
5286
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' scroll-on-clear on',
5287
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' automatic-rename off'
5288
- ];
5289
- for (const command of commands) {
5290
- try {
5291
- execSync(command, { stdio: 'ignore' });
5292
- } catch (_) {}
5293
- }
5294
- }
5295
-
5296
- function ensureTmuxSession(accountDir) {
5297
- if (!isTmuxAvailable()) {
5298
- appendGlobalActivityLog('tmux', 'session.create_skipped.tmux_unavailable', { accountDir }, 'error');
5299
- return false;
5300
- }
5301
- if (tmuxSessionExists()) return;
5302
- execSync(
5303
- 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(accountDir),
5304
- { stdio: 'ignore' }
5305
- );
5306
- configureTmuxSession();
5307
- console.log('[listener] Created tmux session:', TMUX_SESSION_NAME);
5308
- appendGlobalActivityLog('tmux', 'session.created', {
5309
- sessionName: TMUX_SESSION_NAME,
5310
- accountDir
5311
- });
5312
- return true;
5313
- }
5314
-
5315
- function canLaunchTerminalForTmux() {
5316
- return process.platform === 'darwin';
5317
- }
5318
-
5319
- function openTmuxSessionForUser() {
5320
- if (!isTmuxAvailable()) {
5321
- throw new Error('tmux is not installed');
5322
- }
5323
-
5324
- if (!tmuxSessionExists()) {
5325
- ensureTmuxSession(os.homedir());
5326
- }
5327
-
5328
- const attachCommand = 'tmux attach -t ' + TMUX_SESSION_NAME + ' || tmux new-session -A -s ' + TMUX_SESSION_NAME;
5329
-
5330
- if (process.platform === 'darwin') {
5331
- const osaScript = [
5332
- 'tell application "Terminal"',
5333
- 'activate',
5334
- 'do script ' + JSON.stringify(attachCommand),
5335
- 'end tell'
5336
- ].join('\n');
5337
- execSync('osascript -e ' + shellQuote(osaScript), { stdio: 'ignore' });
5338
- appendGlobalActivityLog('tmux', 'terminal.opened', {
5339
- sessionName: TMUX_SESSION_NAME,
5340
- command: attachCommand
5341
- });
5342
- return {
5343
- success: true,
5344
- method: 'terminal',
5345
- command: attachCommand
5346
- };
5347
- }
5348
-
5349
- throw new Error('Opening a terminal window is not supported on this platform yet. Run `' + attachCommand + '` manually.');
5350
- }
5351
-
5352
- function dispatchWindowName(accountId, routingKey) {
5353
- const base = routingKey || ('account:' + accountId) || 'dispatch';
5354
- return base
5355
- .replace(/[^a-zA-Z0-9:_-]+/g, '-')
5356
- .slice(0, 40) || 'dispatch';
5357
- }
5358
-
5359
- function resolveAgentCommand(agent, prompt) {
5360
- const escapedPrompt = prompt.replace(/'/g, "'\"'\"'");
5361
- // All agents are launched in interactive mode so the user can watch and interact.
5362
- // claude: positional arg starts interactive session with initial prompt
5363
- // codex: positional arg starts interactive session with initial prompt
5364
- // gemini: positional arg starts interactive session with initial prompt
5365
- switch (agent) {
5366
- case 'claude':
5367
- return "claude '" + escapedPrompt + "'";
5368
- case 'codex':
5369
- return "codex '" + escapedPrompt + "'";
5370
- case 'gemini':
5371
- return "gemini '" + escapedPrompt + "'";
5372
- default:
5373
- return "claude '" + escapedPrompt + "'";
5374
- }
5375
- }
5376
-
5377
- function resolveAccountDir(accountId) {
5378
- if (!fs.existsSync(ACCOUNT_REPO_INDEX_FILE)) return null;
5379
- try {
5380
- const index = JSON.parse(fs.readFileSync(ACCOUNT_REPO_INDEX_FILE, 'utf8'));
5381
- const entry = index[String(accountId)];
5382
- if (entry && entry.directory && fs.existsSync(entry.directory)) {
5383
- return entry.directory;
5384
- }
5385
- } catch (_) {}
5386
- return null;
5387
- }
5388
-
5389
- function normalizeDispatchAccountId(value) {
5390
- if (value === undefined || value === null) return null;
5391
- const trimmed = String(value).trim();
5392
- return trimmed ? trimmed : null;
5393
- }
5394
-
5395
- function resolveDispatchAccountId(message, fallbackAccountId) {
5396
- const candidates = [
5397
- normalizeDispatchAccountId(message && message.implementationAccountId),
5398
- normalizeDispatchAccountId(message && message.accountId),
5399
- normalizeDispatchAccountId(fallbackAccountId)
5400
- ].filter(Boolean);
5401
-
5402
- const uniqueCandidates = [];
5403
- for (const candidate of candidates) {
5404
- if (!uniqueCandidates.includes(candidate)) {
5405
- uniqueCandidates.push(candidate);
5406
- }
5407
- }
5408
-
5409
- for (const candidate of uniqueCandidates) {
5410
- if (resolveAccountDir(candidate)) {
5411
- return candidate;
5412
- }
5413
- }
5414
-
5415
- return uniqueCandidates[0] || null;
5416
- }
5417
-
5418
- function sendKeysToPane(paneId, text) {
5419
- // Paste through a tmux buffer so multiline follow-ups are injected reliably
5420
- // into interactive agent UIs, then submit them explicitly.
5421
- const bufferName = 'remits-cli-' + crypto.randomUUID();
5422
- try {
5423
- execSync(
5424
- 'tmux load-buffer -b ' + shellQuote(bufferName) + ' -',
5425
- { input: text, stdio: ['pipe', 'ignore', 'ignore'] }
5426
- );
5427
- execSync(
5428
- 'tmux paste-buffer -d -b ' + shellQuote(bufferName) + ' -t ' + shellQuote(paneId),
5429
- { stdio: 'ignore' }
5430
- );
5431
- execSync(
5432
- 'tmux send-keys -t ' + shellQuote(paneId) + ' Enter',
5433
- { stdio: 'ignore' }
5434
- );
5435
- } finally {
5436
- try {
5437
- execSync('tmux delete-buffer -b ' + shellQuote(bufferName), { stdio: 'ignore' });
5438
- } catch (_) {}
5439
- }
5440
- }
5441
-
5442
- function buildDispatchPrompt(message, options) {
5443
- const dispatchOptions = options && typeof options === 'object' ? options : {};
5444
- const textPrompt = message && (message.prompt || message.message);
5445
- const context = message && typeof message === 'object' ? Object.assign({}, message) : {};
5446
- delete context.prompt;
5447
- delete context.message;
5448
-
5449
- const instructionLines = [
5450
- 'This message was delivered by the remits-cli tool from an inbound platform message.',
5451
- 'When triaging and handling this request, load and use the remits-cli skill.'
5452
- ];
5453
-
5454
- if (dispatchOptions.noAccountDir === true) {
5455
- instructionLines.push('remits-cli dispatch status: no_account_dir.');
5456
- instructionLines.push('The listener could not resolve an account directory from the ticket payload, but you should still handle the request and work with the user to gather any additional account details if needed.');
5457
- }
5458
-
5459
- if (textPrompt) {
5460
- instructionLines.push('');
5461
- instructionLines.push('Inbound message:');
5462
- instructionLines.push(textPrompt);
5463
- }
5464
-
5465
- if (Object.keys(context).length) {
5466
- instructionLines.push('');
5467
- instructionLines.push('Dispatch context:');
5468
- instructionLines.push('```json');
5469
- instructionLines.push(JSON.stringify(context, null, 2));
5470
- instructionLines.push('```');
5471
- }
5472
-
5473
- return instructionLines.join('\n');
5474
- }
5475
-
5476
- function dispatchToAgent(accountId, message) {
5477
- const accountDir = resolveAccountDir(accountId);
5478
-
5479
- if (!isTmuxAvailable()) {
5480
- console.error('[listener] tmux is not installed. Cannot dispatch agent session.');
5481
- appendGlobalActivityLog('tmux', 'dispatch.skipped.tmux_unavailable', {
5482
- accountId,
5483
- accountDir,
5484
- message: summarizeDispatchMessage(message)
5485
- }, 'error');
5486
- return;
5487
- }
5488
-
5489
- const ticketId = dispatchTicketId(message);
5490
- const routingKey = dispatchRoutingKey(message);
5491
- const prompt = buildDispatchPrompt(message, {
5492
- noAccountDir: !accountDir
5493
- });
5494
-
5495
- if (!accountDir) {
5496
- console.error('[listener] No known directory for account', accountId, '- dispatching anyway');
5497
- appendGlobalActivityLog('tmux', 'dispatch.no_account_dir', {
5498
- accountId,
5499
- message: summarizeDispatchMessage(message)
5500
- }, 'error');
5501
- }
5502
-
5503
- // If this ticket already has a tracked pane, check its state
5504
- if (routingKey && paneMap[routingKey]) {
5505
- const existingPaneId = paneMap[routingKey];
5506
- if (tmuxPaneIsAlive(existingPaneId)) {
5507
- // Pane is alive, so deliver the follow-up to the existing ticket workstream
5508
- console.log('[listener] Delivering follow-up for', routingKey, 'to existing pane', existingPaneId);
5509
- appendGlobalActivityLog('tmux', 'dispatch.follow_up', {
5510
- accountId,
5511
- accountDir,
5512
- ticketId,
5513
- routingKey,
5514
- paneId: existingPaneId,
5515
- message: summarizeDispatchMessage(message)
5516
- });
5517
- try {
5518
- sendKeysToPane(existingPaneId, prompt);
5519
- return;
5520
- } catch (err) {
5521
- console.error('[listener] Failed to send to pane', existingPaneId, ':', err.message, '- creating new pane');
5522
- appendGlobalActivityLog('tmux', 'dispatch.follow_up_failed', {
5523
- accountId,
5524
- accountDir,
5525
- ticketId,
5526
- routingKey,
5527
- paneId: existingPaneId,
5528
- error: err.message,
5529
- message: summarizeDispatchMessage(message)
5530
- }, 'error');
5531
- }
5532
- }
5533
- // Pane is dead or gone, so replace it for this ticket/task routing key.
5534
- console.log('[listener] Pane', existingPaneId, 'for', routingKey, 'is dead or gone - replacing');
5535
- appendGlobalActivityLog('tmux', 'dispatch.pane_replaced', {
5536
- accountId,
5537
- accountDir,
5538
- ticketId,
5539
- routingKey,
5540
- paneId: existingPaneId,
5541
- message: summarizeDispatchMessage(message)
5542
- });
5543
- killPane(existingPaneId);
5544
- delete paneMap[routingKey];
5545
- savePaneMap();
5546
- }
5547
-
5548
- // Create a new pane for this task
5549
- try {
5550
- const paneDir = accountDir || os.homedir();
5551
- ensureTmuxSession(paneDir);
5552
-
5553
- const agent = getPreferredAgent();
5554
- const agentCmd = resolveAgentCommand(agent, prompt);
5555
- const windowName = dispatchWindowName(accountId, routingKey);
5556
-
5557
- // Wrap in a login shell so the pane inherits the user's full PATH
5558
- // (the daemonized listener process has a minimal environment)
5559
- const userShell = process.env.SHELL || '/bin/zsh';
5560
- const loginCmd = userShell + ' -l -c ' + shellQuote(agentCmd);
5561
-
5562
- // Create a dedicated window for this workstream so full-screen agent UIs
5563
- // remain readable and easy to inspect.
5564
- const paneInfo = execSync(
5565
- 'tmux new-window -t ' + shellQuote(TMUX_SESSION_NAME) +
5566
- ' -n ' + shellQuote(windowName) +
5567
- ' -c ' + shellQuote(paneDir) +
5568
- ' -P -F ' + shellQuote('#{pane_id}') +
5569
- ' ' + shellQuote(loginCmd),
5570
- { stdio: ['ignore', 'pipe', 'ignore'] }
5571
- ).toString().trim();
5572
-
5573
- console.log('[listener] Dispatched', routingKey || '(untracked message)', 'to', agent, 'in pane', paneInfo, 'at', accountDir);
5574
- appendGlobalActivityLog('tmux', 'dispatch.created', {
5575
- accountId,
5576
- accountDir: paneDir,
5577
- resolvedAccountDir: accountDir,
5578
- ticketId,
5579
- routingKey,
5580
- paneId: paneInfo,
5581
- agent,
5582
- message: summarizeDispatchMessage(message)
5583
- });
5584
-
5585
- if (routingKey) {
5586
- paneMap[routingKey] = paneInfo;
5587
- savePaneMap();
5588
- }
5589
- } catch (err) {
5590
- console.error('[listener] Failed to create tmux pane:', err.message);
5591
- appendGlobalActivityLog('tmux', 'dispatch.create_failed', {
5592
- accountId,
5593
- accountDir,
5594
- ticketId,
5595
- routingKey,
5596
- error: err.message,
5597
- agent: getPreferredAgent(),
5598
- message: summarizeDispatchMessage(message)
5599
- }, 'error');
5600
- }
5601
- }
5602
-
5603
- function publishMissedMessageRequest(client, baseUrl, userId) {
5604
- if (!userId) {
5605
- return;
5606
- }
5607
- const since = lastMessageTimestampForScope(baseUrl, userId);
5608
- client.publish({
5609
- destination: '/app/missed-messages',
5610
- body: JSON.stringify({
5611
- userId: String(userId),
5612
- since
5613
- })
5614
- });
5615
- appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
5616
- baseUrl,
5617
- userId,
5618
- since
5619
- });
5620
- }
5621
-
5622
- function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
5623
- if (!messageId) {
5624
- return;
5625
- }
5626
- const primaryUserId = userIds.find(Boolean);
5627
- const body = primaryUserId
5628
- ? { messageId, userId: String(primaryUserId) }
5629
- : { messageId };
5630
- client.publish({
5631
- destination: '/app/ack',
5632
- body: JSON.stringify(body)
5633
- });
5634
- }
5635
-
5636
- function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
5637
- const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
5638
- const messageId = data.messageId ? String(data.messageId) : null;
5639
- const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
5640
-
5641
- recordWebsocketMessage(baseUrl, topic, data, {
5642
- source,
5643
- duplicate,
5644
- accountIds
5645
- });
5646
-
5647
- if (messageId) {
5648
- acknowledgeWebsocketMessage(client, messageId, userIds);
5649
- }
5650
-
5651
- updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
5652
- runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
5653
- if (runtimeState.websocket[baseUrl].topics[topic]) {
5654
- runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
5655
- }
5656
-
5657
- if (duplicate) {
5658
- appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
5659
- baseUrl,
5660
- topic,
5661
- messageId,
5662
- source
5663
- });
5664
- updateRuntimeServiceState();
5665
- return;
5666
- }
5667
-
5668
- if (messageId) {
5669
- rememberProcessedMessageId(messageId);
5670
- }
5671
-
5672
- if (data.type === 'TestSuite') {
5673
- const payload = data.payload || {};
5674
- const payloadTaskId = payload.taskId || null;
5675
- const currentTestTaskId = runtimeState.currentTestTaskId || null;
5676
- if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
5677
- updateRuntimeServiceState();
5678
- return;
5679
- }
5680
- if (payload.tests || payload.name || payload.total != null) {
5681
- const acctId = payload.accountId || accountIds[0];
5682
- console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
5683
- }
5684
- } else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
5685
- const payload = data.payload || data;
5686
- const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
5687
- console.log('[ws][listener][account:' + dispatchAccountId + ']', JSON.stringify(payload));
5688
- appendGlobalActivityLog('tmux', 'listener_delivery.received', {
5689
- baseUrl,
5690
- topic,
5691
- accountId: dispatchAccountId,
5692
- messageType: data.type,
5693
- source,
5694
- messageId,
5695
- message: summarizeDispatchMessage(payload)
5696
- });
5697
- dispatchToAgent(dispatchAccountId, payload);
5698
- }
5699
-
5700
- updateRuntimeServiceState();
5163
+ updateRuntimeServiceState();
5701
5164
  }
5702
5165
 
5703
5166
  function createManagedWebsocketClient(baseUrl, urlSessions) {
@@ -5915,42 +5378,6 @@ async function replaceManagedWebsocketClients(entries, reason) {
5915
5378
  });
5916
5379
  }
5917
5380
 
5918
- function pruneDeadPaneMappings() {
5919
- const removed = [];
5920
- for (const [routingKey, paneId] of Object.entries(paneMap)) {
5921
- if (tmuxPaneIsAlive(paneId)) {
5922
- continue;
5923
- }
5924
- delete paneMap[routingKey];
5925
- removed.push({ routingKey, paneId });
5926
- }
5927
- if (removed.length) {
5928
- savePaneMap();
5929
- appendGlobalActivityLog('tmux', 'dispatch_state.pruned', {
5930
- removed
5931
- });
5932
- }
5933
- }
5934
-
5935
- function ensureTmuxSessionHealthy(defaultDir) {
5936
- if (!isTmuxAvailable()) {
5937
- return;
5938
- }
5939
- if (tmuxSessionExists()) {
5940
- pruneDeadPaneMappings();
5941
- return;
5942
- }
5943
- const hadPaneMappings = Object.keys(paneMap).length > 0;
5944
- if (hadPaneMappings) {
5945
- paneMap = {};
5946
- savePaneMap();
5947
- appendGlobalActivityLog('tmux', 'dispatch_state.cleared.missing_session', {
5948
- sessionName: TMUX_SESSION_NAME
5949
- }, 'error');
5950
- }
5951
- ensureTmuxSession(defaultDir || os.homedir());
5952
- }
5953
-
5954
5381
  async function runManagedWebsocketWatchdog() {
5955
5382
  const now = Date.now();
5956
5383
  for (const managedClient of managedClients) {
@@ -6000,6 +5427,663 @@ async function runManagedWebsocketWatchdog() {
6000
5427
  }
6001
5428
 
6002
5429
  // --- Persistent WebSocket listener ---
5430
+ // ---------------------------------------------------------------------------------------------
5431
+ // Local agent sessions
5432
+ //
5433
+ // A "CLI agent" is ONE terminal session — one tab running Claude, Codex, or a person — that has
5434
+ // registered itself as available to work support tickets. Three tabs are three agents, each
5435
+ // independently routable and each reporting its own activity.
5436
+ //
5437
+ // Two halves, deliberately separate:
5438
+ // * PRESENCE lives on the platform with a short TTL, refreshed by a heartbeat sidecar that dies
5439
+ // with the tab it belongs to. A closed terminal therefore stops being routable within a couple
5440
+ // of minutes without anyone having to deregister cleanly, and a crash and a quit look the same.
5441
+ // * ASSIGNMENT lives on the ticket. That is why an agent can restart its terminal, or be offline
5442
+ // when work is routed to it, and still find that work waiting.
5443
+ //
5444
+ // Nothing here pushes work at a terminal. An AI agent mid-task cannot receive a push, so it ASKS:
5445
+ // `remits-cli agent work` polls for tickets routed to it. That is the whole delivery mechanism.
5446
+ // ---------------------------------------------------------------------------------------------
5447
+
5448
+ const AGENT_STATE_FILE = path.join(SESSION_DIR, 'agents.json');
5449
+ const AGENT_HEARTBEAT_INTERVAL_MS = Number(process.env.REMITS_CLI_AGENT_HEARTBEAT_MS || 30000);
5450
+ const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5000);
5451
+ const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
5452
+ const AGENT_STATES = ['idle', 'working', 'paused'];
5453
+
5454
+ function readAgentStore() {
5455
+ if (!fs.existsSync(AGENT_STATE_FILE)) {
5456
+ return { agents: {} };
5457
+ }
5458
+ try {
5459
+ const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
5460
+ return data && typeof data === 'object' && data.agents ? data : { agents: {} };
5461
+ } catch (_) {
5462
+ return { agents: {} };
5463
+ }
5464
+ }
5465
+
5466
+ function writeAgentStore(store) {
5467
+ ensureSessionDir();
5468
+ atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
5469
+ }
5470
+
5471
+ function processIsAlive(pid) {
5472
+ const numeric = Number(pid);
5473
+ if (!Number.isFinite(numeric) || numeric <= 0) {
5474
+ return false;
5475
+ }
5476
+ try {
5477
+ process.kill(numeric, 0);
5478
+ return true;
5479
+ } catch (err) {
5480
+ // EPERM means the process exists but belongs to another user — still alive.
5481
+ return err && err.code === 'EPERM';
5482
+ }
5483
+ }
5484
+
5485
+ /**
5486
+ * The locally-known agent sessions, dropping any whose anchor process has gone.
5487
+ *
5488
+ * The store is a local convenience so a follow-up command in the same tab resolves the same agent
5489
+ * without being told its id. It is never the source of truth: the platform's registry is.
5490
+ */
5491
+ function listLocalAgents() {
5492
+ const store = readAgentStore();
5493
+ const alive = {};
5494
+ let changed = false;
5495
+ for (const [agentId, entry] of Object.entries(store.agents || {})) {
5496
+ if (entry && processIsAlive(entry.anchorPid)) {
5497
+ alive[agentId] = entry;
5498
+ } else {
5499
+ changed = true;
5500
+ }
5501
+ }
5502
+ if (changed) {
5503
+ writeAgentStore({ agents: alive });
5504
+ }
5505
+ return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
5506
+ }
5507
+
5508
+ function rememberLocalAgent(entry) {
5509
+ const store = readAgentStore();
5510
+ store.agents = store.agents || {};
5511
+ store.agents[entry.agentId] = entry;
5512
+ writeAgentStore(store);
5513
+ return entry;
5514
+ }
5515
+
5516
+ function forgetLocalAgent(agentId) {
5517
+ const store = readAgentStore();
5518
+ if (store.agents && store.agents[agentId]) {
5519
+ delete store.agents[agentId];
5520
+ writeAgentStore(store);
5521
+ return true;
5522
+ }
5523
+ return false;
5524
+ }
5525
+
5526
+ /**
5527
+ * The process whose death means "this agent session is over".
5528
+ *
5529
+ * Not our own parent: every `remits-cli` invocation from Claude Code, Codex, or a shell script gets
5530
+ * a throwaway shell as its parent, and anchoring to that would kill the heartbeat the instant the
5531
+ * registering command returned. So walk UP the process tree and anchor to the first ancestor that
5532
+ * is recognisably the agent itself, then to the first interactive shell, and only then give up.
5533
+ */
5534
+ function resolveAgentAnchorPid() {
5535
+ const explicit = Number(process.env.REMITS_AGENT_ANCHOR_PID);
5536
+ if (Number.isFinite(explicit) && explicit > 0 && processIsAlive(explicit)) {
5537
+ return { pid: explicit, source: 'env', command: null };
5538
+ }
5539
+
5540
+ const agentPattern = /(^|\/|\s)(claude|codex|gemini|cursor-agent|aider)(\s|$)/i;
5541
+ const shellPattern = /(^|\/)(zsh|bash|fish|sh|ksh)$/i;
5542
+ let shellCandidate = null;
5543
+
5544
+ try {
5545
+ let pid = process.ppid;
5546
+ for (let depth = 0; depth < 12 && pid && pid > 1; depth += 1) {
5547
+ // pid 1 is launchd/init and never exits. Anchoring to it would register an agent that can never
5548
+ // go offline, so the walk stops short of it and the caller refuses below.
5549
+
5550
+ const line = execFileSync('ps', ['-o', 'ppid=,command=', '-p', String(pid)], {
5551
+ stdio: ['ignore', 'pipe', 'ignore']
5552
+ }).toString().trim();
5553
+ if (!line) break;
5554
+ const match = line.match(/^\s*(\d+)\s+(.*)$/);
5555
+ if (!match) break;
5556
+ const parentPid = Number(match[1]);
5557
+ const command = match[2];
5558
+ if (agentPattern.test(command)) {
5559
+ return { pid, source: 'agent', command };
5560
+ }
5561
+ if (!shellCandidate && shellPattern.test(String(command).split(/\s+/)[0] || '')) {
5562
+ shellCandidate = { pid, source: 'shell', command };
5563
+ }
5564
+ pid = parentPid;
5565
+ }
5566
+ } catch (_) {
5567
+ // ps is unavailable (or restricted). Fall through to the parent process.
5568
+ }
5569
+
5570
+ if (shellCandidate) {
5571
+ return shellCandidate;
5572
+ }
5573
+ const parent = process.ppid;
5574
+ if (parent && parent > 1 && processIsAlive(parent)) {
5575
+ return { pid: parent, source: 'parent', command: null };
5576
+ }
5577
+ // Nothing mortal to anchor to — typically a detached or orphaned process, whose parent is init.
5578
+ // Registering here would create an agent that never expires and keeps receiving tickets forever,
5579
+ // so refuse instead. Callers with a real session should pass REMITS_AGENT_ANCHOR_PID.
5580
+ return { pid: null, source: 'none', command: null };
5581
+ }
5582
+
5583
+ function agentLabelDefault(anchor, cwd) {
5584
+ const repo = path.basename(cwd || process.cwd());
5585
+ const kind = anchor && anchor.command && /claude/i.test(anchor.command) ? 'claude'
5586
+ : anchor && anchor.command && /codex/i.test(anchor.command) ? 'codex'
5587
+ : anchor && anchor.command && /gemini/i.test(anchor.command) ? 'gemini'
5588
+ : getPreferredAgent();
5589
+ return kind + '@' + repo;
5590
+ }
5591
+
5592
+ /**
5593
+ * Which agent session is THIS command acting as?
5594
+ *
5595
+ * Explicit beats implicit, and ambiguity is an error rather than a guess: routing work to the wrong
5596
+ * tab is silent and confusing, whereas a message naming the candidates is neither.
5597
+ */
5598
+ function resolveAgentId(flags, options = {}) {
5599
+ const explicit = (flags && (flags['agent-id'] || flags.agentId)) || process.env.REMITS_AGENT_ID;
5600
+ if (explicit) {
5601
+ return String(explicit).trim();
5602
+ }
5603
+ const local = listLocalAgents();
5604
+ if (!local.length) {
5605
+ if (options.optional) return null;
5606
+ throw new Error('No local agent is registered in this session. Run: remits-cli agent register');
5607
+ }
5608
+ const cwd = process.cwd();
5609
+ const inCwd = local.filter((entry) => entry.cwd === cwd);
5610
+ const candidates = inCwd.length ? inCwd : local;
5611
+ if (candidates.length === 1) {
5612
+ return candidates[0].agentId;
5613
+ }
5614
+ throw new Error(
5615
+ 'Several local agents are registered. Pass --agent-id (or set REMITS_AGENT_ID). Candidates:\n' +
5616
+ candidates.map((entry) => ' ' + entry.agentId + ' ' + (entry.label || '') + ' ' + entry.cwd).join('\n')
5617
+ );
5618
+ }
5619
+
5620
+ /**
5621
+ * The host and lane a support agent registers against when it is told nothing.
5622
+ *
5623
+ * An agent works REAL support tickets, and it must be startable from any directory on the machine —
5624
+ * not only from inside an account repo. So it defaults to the platform host the CLI ships with
5625
+ * (`REMITS_BASE_URL` when set) and to the `prod` lane.
5626
+ *
5627
+ * That is a deliberate exception to the CLI's test-first default, which exists to stop a developer
5628
+ * ITERATING from touching live data. Registering presence mutates no business data, and a test-lane
5629
+ * agent is silently useless for support: it registers, appears online, and can never be routed a
5630
+ * production ticket. Ask for `--data-mode test` when you want a fixture agent.
5631
+ */
5632
+ function agentDefaults(flags, entry) {
5633
+ return {
5634
+ baseUrl: normalizeBaseUrl(
5635
+ (flags && flags['base-url']) || (entry && entry.baseUrl) || DEFAULT_BASE_URL),
5636
+ dataMode: normalizeDataMode(
5637
+ (flags && (flags['data-mode'] || flags.dataMode)) || (entry && entry.dataMode) || 'prod')
5638
+ };
5639
+ }
5640
+
5641
+ function agentSessionFor(flags, entry) {
5642
+ const cwd = (entry && entry.cwd) || process.cwd();
5643
+ const defaults = agentDefaults(flags, entry);
5644
+ const context = resolveSessionContext(cwd, Object.assign({}, flags, {
5645
+ 'base-url': defaults.baseUrl,
5646
+ 'data-mode': defaults.dataMode
5647
+ }));
5648
+ if (!context.session || !context.session.token) {
5649
+ throw new Error('No authenticated session for ' + defaults.baseUrl +
5650
+ '. Run: remits-cli auth --base-url ' + defaults.baseUrl);
5651
+ }
5652
+ // resolveSessionContext names it `resolvedBaseUrl` and resolves no data lane, so normalize both here
5653
+ // rather than letting each caller re-derive them. Destructuring `{ baseUrl, dataMode }` off it yields
5654
+ // undefined silently, and the failure then surfaces far away as an axios "Invalid URL".
5655
+ return {
5656
+ session: context.session,
5657
+ accountId: context.accountId,
5658
+ baseUrl: defaults.baseUrl,
5659
+ dataMode: defaults.dataMode,
5660
+ sessionResolutionWarning: context.sessionResolutionWarning
5661
+ };
5662
+ }
5663
+
5664
+ /** Every account repo checked out on this machine, as registration claims. The platform verifies them. */
5665
+ function agentAccountClaims(cwd) {
5666
+ const claims = listAccountRepoEntries(readAccountRepoIndex()).map((entry) => ({
5667
+ accountId: Number(entry.accountId),
5668
+ name: entry.name || null,
5669
+ type: entry.type || null,
5670
+ platformAccountId: entry.platformAccountId || null,
5671
+ directory: entry.directory || null,
5672
+ routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
5673
+ ? 'self_and_descendants'
5674
+ : 'self',
5675
+ source: entry.source || 'repo'
5676
+ })).filter((entry) => Number.isFinite(entry.accountId) && entry.accountId > 0);
5677
+
5678
+ // The repo the command was run from leads, so a tab working one account is routed that account's
5679
+ // tickets first even when the machine has a dozen checkouts.
5680
+ try {
5681
+ const here = loadAccountId(cwd);
5682
+ const index = claims.findIndex((entry) => entry.accountId === Number(here));
5683
+ if (index > 0) {
5684
+ claims.unshift(claims.splice(index, 1)[0]);
5685
+ }
5686
+ } catch (_) { /* not in an account repo */ }
5687
+ return claims;
5688
+ }
5689
+
5690
+ async function agentRegisterCommand(flags, options = {}) {
5691
+ const cwd = process.cwd();
5692
+ try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
5693
+ try { discoverAccountRepos(); } catch (_) { /* best-effort */ }
5694
+
5695
+ const { session, baseUrl, dataMode } = agentSessionFor(flags, null);
5696
+ const anchor = resolveAgentAnchorPid();
5697
+ if (!anchor.pid) {
5698
+ throw new Error(
5699
+ 'Could not find a process to anchor this agent to, so registering would create an agent that ' +
5700
+ 'never goes offline. Run this from an interactive terminal, or set REMITS_AGENT_ANCHOR_PID to ' +
5701
+ 'the pid whose exit should end this agent session.');
5702
+ }
5703
+ const explicitId = (flags['agent-id'] || flags.agentId || process.env.REMITS_AGENT_ID || '').trim();
5704
+ // Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
5705
+ // to let an unrelated record be adopted whenever two registrations shared an anchor.
5706
+ const existingId = explicitId ||
5707
+ (listLocalAgents().find((entry) =>
5708
+ entry.cwd === cwd && entry.anchorPid === anchor.pid && entry.anchorSource === anchor.source) || {}).agentId ||
5709
+ null;
5710
+
5711
+ const api = buildAxios(baseUrl, session.token, 15000);
5712
+ const payload = {
5713
+ agentId: existingId || undefined,
5714
+ label: flags.label || agentLabelDefault(anchor, cwd),
5715
+ agentType: flags.agent || getPreferredAgent(),
5716
+ cliVersion: readCliVersion(),
5717
+ host: os.hostname(),
5718
+ pid: anchor.pid,
5719
+ cwd,
5720
+ dataMode,
5721
+ accountId: Number(session.accountId) || undefined,
5722
+ accounts: agentAccountClaims(cwd),
5723
+ state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
5724
+ activity: flags.activity || null,
5725
+ source: flags.source || 'repo'
5726
+ };
5727
+
5728
+ const response = await api.post('/cli/agentRegister', payload).then((r) => r.data);
5729
+ if (!response || response.success === false) {
5730
+ throw new Error((response && response.message) || 'Agent registration was refused.');
5731
+ }
5732
+
5733
+ const entry = rememberLocalAgent({
5734
+ agentId: response.agentId,
5735
+ label: payload.label,
5736
+ cwd,
5737
+ baseUrl,
5738
+ dataMode,
5739
+ // The accounts the platform ACCEPTED, so `agent list` can scope itself to the work this session
5740
+ // can actually be routed rather than to whichever session happened to be most recent.
5741
+ accountIds: response.accountIds || [],
5742
+ accountId: Number(session.accountId) || null,
5743
+ anchorPid: anchor.pid,
5744
+ anchorSource: anchor.source,
5745
+ registeredAtMs: Date.now()
5746
+ });
5747
+
5748
+ // A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
5749
+ // re-registering, and the daemons multiply for as long as the platform stays unreachable.
5750
+ if (options.spawnHeartbeat !== false) {
5751
+ startAgentHeartbeatDaemon(entry);
5752
+ }
5753
+
5754
+ if (flagEnabled(flags.json)) {
5755
+ console.log(JSON.stringify({ ...response, local: entry }, null, 2));
5756
+ return;
5757
+ }
5758
+
5759
+ const accepted = response.accountIds || [];
5760
+ console.log('Registered as a support agent: ' + response.agentId);
5761
+ console.log(' label ' + payload.label);
5762
+ console.log(' platform ' + baseUrl);
5763
+ console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (live support tickets)' : ' (fixtures only)'));
5764
+ console.log(' accounts ' + accepted.length + ' account(s) this session can be routed work for');
5765
+ console.log(' ' + accepted.join(', '));
5766
+ console.log(' heartbeat every ' + Math.round(AGENT_HEARTBEAT_INTERVAL_MS / 1000) + 's, anchored to pid ' +
5767
+ anchor.pid + ' (' + anchor.source + '), ends when this session ends');
5768
+ if ((response.skippedAccounts || []).length) {
5769
+ console.log(' refused ' + response.skippedAccounts
5770
+ .map((entry) => entry.accountId + ' (' + entry.reason + ')').join(', '));
5771
+ }
5772
+ if (dataMode !== 'prod') {
5773
+ console.log('');
5774
+ console.log('NOTE: a ' + dataMode + '-lane agent never receives production tickets. Re-register with');
5775
+ console.log(' --data-mode prod to take real support work.');
5776
+ }
5777
+ console.log('');
5778
+ console.log('You are on duty. The support loop, repeated for as long as you are working:');
5779
+ console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
5780
+ console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
5781
+ console.log(' 3. work the ticket; keep step 2 current as you change what you are doing');
5782
+ console.log(' 4. close it out via mcp_support_ticket (accept -> update_status -> complete)');
5783
+ console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
5784
+ }
5785
+
5786
+ function readCliVersion() {
5787
+ try {
5788
+ return require(path.join(__dirname, 'package.json')).version || null;
5789
+ } catch (_) {
5790
+ return null;
5791
+ }
5792
+ }
5793
+
5794
+ /**
5795
+ * Spawn the detached heartbeat that keeps this session visible.
5796
+ *
5797
+ * It is a separate process on purpose: the registering command must return immediately so the agent
5798
+ * can get on with its work, and presence must continue between commands — an agent that only
5799
+ * appeared online while it happened to be running remits-cli would be invisible for exactly the
5800
+ * period it was busy working a ticket.
5801
+ */
5802
+ function startAgentHeartbeatDaemon(entry) {
5803
+ const { spawn } = require('child_process');
5804
+ const child = spawn(process.execPath, [
5805
+ __filename, 'agent', 'heartbeat-daemon',
5806
+ '--agent-id', entry.agentId,
5807
+ '--anchor-pid', String(entry.anchorPid),
5808
+ '--base-url', entry.baseUrl,
5809
+ '--cwd', entry.cwd
5810
+ ], { detached: true, stdio: 'ignore', env: { ...process.env } });
5811
+ child.unref();
5812
+ appendGlobalActivityLog('agent', 'heartbeat.started', {
5813
+ agentId: entry.agentId,
5814
+ anchorPid: entry.anchorPid,
5815
+ heartbeatPid: child.pid
5816
+ });
5817
+ return child.pid;
5818
+ }
5819
+
5820
+ async function agentHeartbeatDaemonCommand(flags) {
5821
+ const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
5822
+ const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
5823
+ const cwd = flags.cwd || process.cwd();
5824
+ if (!agentId || !Number.isFinite(anchorPid)) {
5825
+ throw new Error('agent heartbeat-daemon requires --agent-id and --anchor-pid.');
5826
+ }
5827
+
5828
+ // The daemon runs detached, so its own process tree says nothing about the session it is watching.
5829
+ // Re-registration must reuse the anchor it was given, never re-resolve one.
5830
+ process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
5831
+
5832
+ const stop = async (reason) => {
5833
+ appendGlobalActivityLog('agent', 'heartbeat.stopped', { agentId, anchorPid, reason });
5834
+ forgetLocalAgent(agentId);
5835
+ try {
5836
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
5837
+ await buildAxios(context.baseUrl, context.session.token, 5000)
5838
+ .post('/cli/agentDeregister', { agentId });
5839
+ } catch (_) {
5840
+ // A courtesy goodbye. The registry TTL is the mechanism; this only makes it immediate.
5841
+ }
5842
+ process.exit(0);
5843
+ };
5844
+
5845
+ process.on('SIGTERM', () => { stop('signal'); });
5846
+ process.on('SIGINT', () => { stop('signal'); });
5847
+
5848
+ for (;;) {
5849
+ if (!processIsAlive(anchorPid)) {
5850
+ await stop('anchor_exited');
5851
+ return;
5852
+ }
5853
+ try {
5854
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
5855
+ const response = await buildAxios(context.baseUrl, context.session.token, 10000)
5856
+ .post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
5857
+ if (response && response.registered === false) {
5858
+ // The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
5859
+ // than heartbeating into a void — the tab is still very much alive.
5860
+ appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
5861
+ await agentRegisterCommand(
5862
+ { 'agent-id': agentId, 'base-url': context.baseUrl, json: 'true' },
5863
+ { spawnHeartbeat: false }
5864
+ ).catch((err) => {
5865
+ appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
5866
+ { agentId, error: describeError(err) }, 'error');
5867
+ });
5868
+ }
5869
+ } catch (err) {
5870
+ appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
5871
+ }
5872
+ await new Promise((resolve) => setTimeout(resolve, AGENT_HEARTBEAT_INTERVAL_MS));
5873
+ }
5874
+ }
5875
+
5876
+ async function agentStatusCommand(flags) {
5877
+ const agentId = resolveAgentId(flags);
5878
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
5879
+ const context = agentSessionFor(flags, local);
5880
+
5881
+ const update = { agentId };
5882
+ const positional = flags._ && flags._.length > 2 ? flags._.slice(2).join(' ') : null;
5883
+ const activity = flags.activity || positional;
5884
+ if (flags.state) {
5885
+ if (!AGENT_STATES.includes(String(flags.state))) {
5886
+ throw new Error('--state must be one of: ' + AGENT_STATES.join(', '));
5887
+ }
5888
+ update.state = String(flags.state);
5889
+ }
5890
+ if (activity != null) update.activity = String(activity);
5891
+ if (flags.ticket || flags['ticket-id']) update.ticketId = String(flags.ticket || flags['ticket-id']);
5892
+ if (flags.step) update.step = String(flags.step);
5893
+ if (flags.detail) update.detail = String(flags.detail);
5894
+
5895
+ // Reporting a ticket without a state is unambiguous: you are working it.
5896
+ if (update.ticketId && !update.state) update.state = 'working';
5897
+
5898
+ const response = await buildAxios(context.baseUrl, context.session.token, 15000)
5899
+ .post('/cli/agentHeartbeat', update).then((r) => r.data);
5900
+
5901
+ if (response && response.registered === false) {
5902
+ console.log('This agent is no longer registered (its presence expired). Re-registering.');
5903
+ await agentRegisterCommand({ ...flags, 'agent-id': agentId });
5904
+ return;
5905
+ }
5906
+
5907
+ if (flagEnabled(flags.json)) {
5908
+ console.log(JSON.stringify(response, null, 2));
5909
+ return;
5910
+ }
5911
+ const agent = (response && response.agent) || {};
5912
+ // `||` binds looser than `+`, so writing this as one expression prints only the state and silently
5913
+ // drops the ticket and activity — the two things the caller just set.
5914
+ const parts = [agent.state || 'idle'];
5915
+ if (agent.ticketId) parts.push('ticket ' + agent.ticketId);
5916
+ if (agent.activity) parts.push(agent.activity);
5917
+ console.log(parts.join(' '));
5918
+ }
5919
+
5920
+ /**
5921
+ * Ask for the work routed to this session.
5922
+ *
5923
+ * Polling, not listening: a terminal running an AI agent has no socket to push to, and the routing
5924
+ * decision it is asking about is a durable field on the ticket rather than a message that could be
5925
+ * missed. `--wait` turns a single question into a patient one.
5926
+ */
5927
+ async function agentWorkCommand(flags) {
5928
+ const agentId = resolveAgentId(flags);
5929
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
5930
+ const context = agentSessionFor(flags, local);
5931
+ const api = buildAxios(context.baseUrl, context.session.token, 20000);
5932
+ const waitSec = parsePositiveInt(flags.wait, AGENT_WORK_DEFAULT_WAIT_SEC);
5933
+ const deadline = Date.now() + (waitSec * 1000);
5934
+
5935
+ for (;;) {
5936
+ const response = await api.post('/cli/agentWork', {
5937
+ agentId,
5938
+ statuses: flags.statuses || 'all'
5939
+ }).then((r) => r.data);
5940
+
5941
+ if (response && response.registered === false) {
5942
+ console.log('This agent is no longer registered. Run: remits-cli agent register');
5943
+ process.exitCode = 1;
5944
+ return;
5945
+ }
5946
+
5947
+ const tickets = (response && response.tickets) || [];
5948
+ if (tickets.length || Date.now() >= deadline) {
5949
+ if (flagEnabled(flags.json)) {
5950
+ console.log(JSON.stringify(response, null, 2));
5951
+ return;
5952
+ }
5953
+ if (!tickets.length) {
5954
+ console.log('No tickets are routed to this agent.' +
5955
+ (waitSec ? ' (waited ' + waitSec + 's)' : ''));
5956
+ console.log('Nothing to do — the platform routes work here when an operator or a rule assigns it.');
5957
+ return;
5958
+ }
5959
+ console.log(tickets.length + ' ticket(s) routed to this agent:');
5960
+ for (const ticket of tickets) {
5961
+ console.log('');
5962
+ console.log(' ' + (ticket.id || ticket.ticketId) + ' [' + (ticket.priority || 'medium') + '/' +
5963
+ (ticket.status || 'open') + '/' + (ticket.type || 'question') + ']');
5964
+ console.log(' ' + (ticket.subject || '(no subject)'));
5965
+ console.log(' account ' + (ticket.accountName || '?') + ' (' + (ticket.accountId || '?') + ')' +
5966
+ (ticket.implementationAccountId
5967
+ ? ' implementation ' + ticket.implementationAccountName + ' (' + ticket.implementationAccountId + ')'
5968
+ : ''));
5969
+ if (ticket.description) {
5970
+ console.log(' ' + String(ticket.description).split('\n')[0].slice(0, 200));
5971
+ }
5972
+ }
5973
+ console.log('');
5974
+ console.log('Next: read it in full, then claim it.');
5975
+ console.log(' remits-cli tool --name mcp_support_ticket --input \'{"action":"read","ticketId":"' +
5976
+ (tickets[0].id || tickets[0].ticketId) + '"}\'');
5977
+ console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
5978
+ ' --activity "reproducing the issue"');
5979
+ return;
5980
+ }
5981
+
5982
+ await new Promise((resolve) => setTimeout(resolve, AGENT_WORK_POLL_MS));
5983
+ }
5984
+ }
5985
+
5986
+ async function agentListCommand(flags) {
5987
+ const cwd = process.cwd();
5988
+ const mine = listLocalAgents()[0] || null;
5989
+ const context = agentSessionFor(flags, mine);
5990
+
5991
+ // An explicit --account-id asks about one account. Otherwise, if this machine has a registered
5992
+ // agent, scope to the accounts it was accepted for — the peers it could actually share work with.
5993
+ // Falling back to "whichever session was most recent" answers a question nobody asked.
5994
+ const explicitAccountId = Number(flags && flags['account-id']);
5995
+ const scopedAccountIds = (!Number.isFinite(explicitAccountId) && mine && Array.isArray(mine.accountIds))
5996
+ ? mine.accountIds
5997
+ : null;
5998
+ const accountId = Number.isFinite(explicitAccountId) && explicitAccountId > 0
5999
+ ? explicitAccountId
6000
+ : (scopedAccountIds ? null : resolvePreferredAccountId(cwd, flags, context.session));
6001
+
6002
+ const response = await buildAxios(context.baseUrl, context.session.token, 20000)
6003
+ .post('/cli/agents', {
6004
+ accountId,
6005
+ accountIds: scopedAccountIds,
6006
+ dataMode: context.dataMode,
6007
+ all: flagEnabled(flags.all)
6008
+ }).then((r) => r.data);
6009
+
6010
+ if (flagEnabled(flags.json)) {
6011
+ console.log(JSON.stringify(response, null, 2));
6012
+ return;
6013
+ }
6014
+ const scopeLabel = accountId ? 'account ' + accountId
6015
+ : (scopedAccountIds ? scopedAccountIds.length + ' account(s) this session covers' : 'your accounts');
6016
+ const agents = (response && response.agents) || [];
6017
+ if (!agents.length) {
6018
+ console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
6019
+ return;
6020
+ }
6021
+ console.log(agents.length + ' agent(s) available across ' + scopeLabel + ':');
6022
+ for (const agent of agents) {
6023
+ console.log('');
6024
+ console.log(' ' + agent.agentId);
6025
+ console.log(' ' + (agent.label || agent.userName || '(unlabelled)') + ' ' + (agent.userEmail || ''));
6026
+ console.log(' state ' + (agent.state || 'idle') +
6027
+ (agent.ticketId ? ' ticket ' + agent.ticketId : '') +
6028
+ ' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
6029
+ if (agent.activity) console.log(' doing ' + agent.activity);
6030
+ console.log(' where ' + (agent.host || '?') + ':' + (agent.cwd || '?'));
6031
+ }
6032
+ }
6033
+
6034
+ async function agentReleaseCommand(flags) {
6035
+ const agentId = resolveAgentId(flags, { optional: true });
6036
+ if (!agentId) {
6037
+ console.log('No local agent is registered in this session.');
6038
+ return;
6039
+ }
6040
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
6041
+ const context = agentSessionFor(flags, local);
6042
+ await buildAxios(context.baseUrl, context.session.token, 10000)
6043
+ .post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
6044
+ forgetLocalAgent(agentId);
6045
+ console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
6046
+ }
6047
+
6048
+ async function agentCommand(flags, subcommand) {
6049
+ switch (subcommand) {
6050
+ case 'register': return agentRegisterCommand(flags);
6051
+ case 'status': return agentStatusCommand(flags);
6052
+ case 'work': return agentWorkCommand(flags);
6053
+ case 'list': case 'ls': return agentListCommand(flags);
6054
+ case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
6055
+ case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
6056
+ default:
6057
+ printAgentHelp();
6058
+ return;
6059
+ }
6060
+ }
6061
+
6062
+ function printAgentHelp() {
6063
+ console.log('Usage: remits-cli agent <subcommand>');
6064
+ console.log('');
6065
+ console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
6066
+ console.log(' Make THIS terminal session available to work support tickets. Runs from ANY');
6067
+ console.log(' directory — it registers for every account repo indexed on this machine that your');
6068
+ console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
6069
+ console.log(' production platform and the prod lane, because a support agent works real tickets.');
6070
+ console.log(' Starts a heartbeat that ends when this session ends, so a closed tab stops');
6071
+ console.log(' receiving work on its own.');
6072
+ console.log('');
6073
+ console.log(' work [--wait SECONDS] [--json]');
6074
+ console.log(' Ask for the tickets routed to this session. With --wait it keeps asking until');
6075
+ console.log(' something arrives or the wait runs out.');
6076
+ console.log('');
6077
+ console.log(' status [--state idle|working|paused] [--ticket ID] [--activity "..."] [--step "..."]');
6078
+ console.log(' Report what this session is doing, so operators can see it live.');
6079
+ console.log('');
6080
+ console.log(' list [--account-id ID] [--json] Who else is available for an account.');
6081
+ console.log(' release Deregister this session immediately.');
6082
+ console.log('');
6083
+ console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
6084
+ console.log(' and identity this session registered with.');
6085
+ }
6086
+
6003
6087
  async function listenCommand(flags) {
6004
6088
  if (isListenerRunning()) {
6005
6089
  const state = readServiceState();
@@ -6079,43 +6163,15 @@ async function listenCommand(flags) {
6079
6163
  return;
6080
6164
  }
6081
6165
 
6082
- // Foreground mode — this is the actual long-running listener
6083
- // Clean slate: kill any leftover tmux session and pane state from a previous run
6084
- killTmuxSession();
6085
- paneMap = {};
6086
- savePaneMap();
6087
-
6166
+ // Foreground mode — this is the actual long-running service
6088
6167
  writeListenerPid();
6089
6168
  runtimeState.service.startedAt = new Date().toISOString();
6090
6169
  runtimeState.service.pid = process.pid;
6091
6170
  recordDiscoverySummary(discoverAccountRepos());
6092
6171
  const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
6093
6172
  await startDashboardServer(dashboardPort);
6094
- await bestEffortRegisterCliListeners({ reason: 'listener.start' });
6095
- let lastListenerRegistrationAt = Date.now();
6096
-
6097
- // Create the tmux session immediately so the user can attach right away
6098
- try {
6099
- const defaultDir = os.homedir();
6100
- execSync(
6101
- 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(defaultDir),
6102
- { stdio: 'ignore' }
6103
- );
6104
- configureTmuxSession();
6105
- appendGlobalActivityLog('tmux', 'session.created', {
6106
- sessionName: TMUX_SESSION_NAME,
6107
- accountDir: defaultDir,
6108
- source: 'listener.start'
6109
- });
6110
- } catch (err) {
6111
- console.error('[listener] Failed to create tmux session:', err.message);
6112
- appendGlobalActivityLog('tmux', 'session.create_failed', {
6113
- sessionName: TMUX_SESSION_NAME,
6114
- error: err.message
6115
- }, 'error');
6116
- }
6117
6173
 
6118
- // Clean up on exit — kill tmux session and clear state
6174
+ // Clean up on exit
6119
6175
  let refreshInterval = null;
6120
6176
  const cleanup = () => {
6121
6177
  appendGlobalActivityLog('listener', 'cleanup', { pid: process.pid });
@@ -6136,9 +6192,6 @@ async function listenCommand(flags) {
6136
6192
  } catch (_) {}
6137
6193
  dashboardServer = null;
6138
6194
  }
6139
- killTmuxSession();
6140
- paneMap = {};
6141
- savePaneMap();
6142
6195
  clearListenerPid();
6143
6196
  clearServiceState();
6144
6197
  process.exit(0);
@@ -6151,7 +6204,7 @@ async function listenCommand(flags) {
6151
6204
  appendGlobalActivityLog('listener', 'foreground.started', {
6152
6205
  pid: process.pid,
6153
6206
  accountCount: entries.length,
6154
- logFile: TMUX_ACTIVITY_LOG_FILE,
6207
+ logFile: ACTIVITY_LOG_FILE,
6155
6208
  dashboardUrl: runtimeState.service.dashboardUrl
6156
6209
  });
6157
6210
 
@@ -6166,8 +6219,6 @@ async function listenCommand(flags) {
6166
6219
  }
6167
6220
  maintenanceInFlight = true;
6168
6221
  try {
6169
- ensureTmuxSessionHealthy(os.homedir());
6170
-
6171
6222
  const currentEntries = listenerSessionEntriesFromStore(readAllSessions());
6172
6223
  const currentSignature = listenerSessionSignature(currentEntries);
6173
6224
  if (currentSignature !== activeSessionSignature) {
@@ -6180,11 +6231,6 @@ async function listenCommand(flags) {
6180
6231
  activeSessionSignature = currentSignature;
6181
6232
  }
6182
6233
 
6183
- if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
6184
- await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
6185
- lastListenerRegistrationAt = Date.now();
6186
- }
6187
-
6188
6234
  await runManagedWebsocketWatchdog();
6189
6235
  updateRuntimeServiceState();
6190
6236
  } catch (err) {
@@ -6218,17 +6264,6 @@ async function listenStopCommand() {
6218
6264
  appendGlobalActivityLog('listener', 'stop.no_process');
6219
6265
  }
6220
6266
 
6221
- // Kill the tmux session and clear pane tracking
6222
- if (tmuxSessionExists()) {
6223
- killTmuxSession();
6224
- console.log('Killed tmux session:', TMUX_SESSION_NAME);
6225
- }
6226
- if (fs.existsSync(DISPATCH_STATE_FILE)) {
6227
- atomicWriteFile(DISPATCH_STATE_FILE, '{}');
6228
- appendGlobalActivityLog('tmux', 'dispatch_state.cleared', {
6229
- file: DISPATCH_STATE_FILE
6230
- });
6231
- }
6232
6267
  clearServiceState();
6233
6268
  }
6234
6269
 
@@ -6571,6 +6606,14 @@ async function main() {
6571
6606
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
6572
6607
  const wantsJson = flagEnabled(args.json);
6573
6608
 
6609
+ // The heartbeat sidecar runs for the whole life of a terminal session. It must not auto-update
6610
+ // (re-exec'ing it would drop the anchor it is watching) and it has no use for repo discovery or the
6611
+ // background service, so it short-circuits everything below.
6612
+ if (command === 'agent' && subcommand === 'heartbeat-daemon') {
6613
+ await agentHeartbeatDaemonCommand(args);
6614
+ return;
6615
+ }
6616
+
6574
6617
  if (shouldAutoUpdate(command, args)) {
6575
6618
  const requireSuccessfulAutoUpdate = command === 'start';
6576
6619
  if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
@@ -6578,13 +6621,9 @@ async function main() {
6578
6621
 
6579
6622
  // Best-effort: update account-repo index whenever running from inside a remits repo
6580
6623
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
6581
- if (!wantsJson) {
6582
- try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
6583
- }
6584
-
6585
6624
  // Auto-start the background service if not already running.
6586
6625
  // Skip for lifecycle subcommands and help.
6587
- const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami';
6626
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
6588
6627
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
6589
6628
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
6590
6629
  try {
@@ -6611,7 +6650,12 @@ async function main() {
6611
6650
  console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
6612
6651
  console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
6613
6652
  console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
6614
- console.log(' remits-cli start [--foreground true] [--port 8787]');
6653
+ console.log(' remits-cli agent register # become a support agent (any directory, prod by default)');
6654
+ console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
6655
+ console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
6656
+ console.log(' remits-cli agent list [--account-id ID] # who else is available');
6657
+ console.log(' remits-cli agent release # stop receiving tickets now');
6658
+ console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
6615
6659
  console.log(' remits-cli stop');
6616
6660
  console.log(' remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
6617
6661
  console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
@@ -6760,6 +6804,15 @@ async function main() {
6760
6804
  return;
6761
6805
  }
6762
6806
 
6807
+ if (command === 'agent') {
6808
+ if (wantsHelp) {
6809
+ printAgentHelp();
6810
+ process.exit(0);
6811
+ }
6812
+ await agentCommand(args, subcommand);
6813
+ return;
6814
+ }
6815
+
6763
6816
  if (command === 'install') {
6764
6817
  await installSkillsCommand(args);
6765
6818
  return;
@@ -6789,6 +6842,9 @@ async function main() {
6789
6842
  }
6790
6843
 
6791
6844
  main().catch((err) => {
6845
+ if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
6846
+ console.error(err.stack);
6847
+ }
6792
6848
  console.error('remits-cli error:', describeError(err) + sandboxNetworkHint(err));
6793
6849
  process.exit(1);
6794
6850
  });