@remits/remits-cli 0.1.107 → 0.1.109

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,156 +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
- return {
1333
- client: 'remits-cli',
1334
- reason: options.reason || 'heartbeat',
1335
- baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
1336
- accountId: Number(session.accountId),
1337
- dataMode: normalizeDataMode(session.dataMode),
1338
- websocketTopic: session.websocketTopic || (session.user && session.user.uuid ? String(session.user.uuid).replace(/-/g, '') : null),
1339
- dashboardUrl: service.dashboardUrl || null,
1340
- agent: getPreferredAgent(),
1341
- service,
1342
- repoAccounts: listenerRegistrationRepoAccounts()
1343
- };
1344
- }
1345
-
1346
- function listenerRegistrationSignature(payload) {
1347
- return sha256(stableStringify({
1348
- baseUrl: payload.baseUrl,
1349
- accountId: payload.accountId,
1350
- dataMode: payload.dataMode,
1351
- websocketTopic: payload.websocketTopic,
1352
- dashboardUrl: payload.dashboardUrl,
1353
- agent: payload.agent,
1354
- serviceRunning: payload.service && payload.service.running,
1355
- repoAccounts: (payload.repoAccounts || []).map((entry) => ({
1356
- accountId: entry.accountId,
1357
- type: entry.type,
1358
- routingScope: entry.routingScope,
1359
- updatedAt: entry.updatedAt
1360
- }))
1361
- }));
1362
- }
1363
-
1364
- function shouldRegisterCliListener(session, payload, options = {}) {
1365
- if (options.force === true) {
1366
- return true;
1367
- }
1368
- const state = readListenerRegistrationState();
1369
- const key = listenerRegistrationStateKey(session);
1370
- const existing = state[key];
1371
- if (!existing) {
1372
- return true;
1373
- }
1374
- const lastRegisteredAtMs = Number(existing.lastRegisteredAtMs || 0);
1375
- if (!lastRegisteredAtMs || Date.now() - lastRegisteredAtMs >= LISTENER_REGISTRATION_MIN_INTERVAL_MS) {
1376
- return true;
1377
- }
1378
- return existing.signature !== listenerRegistrationSignature(payload);
1379
- }
1380
-
1381
- function rememberCliListenerRegistration(session, payload) {
1382
- const state = readListenerRegistrationState();
1383
- const key = listenerRegistrationStateKey(session);
1384
- state[key] = {
1385
- baseUrl: payload.baseUrl,
1386
- userId: session.user && session.user.id ? session.user.id : null,
1387
- signature: listenerRegistrationSignature(payload),
1388
- accountCount: Array.isArray(payload.repoAccounts) ? payload.repoAccounts.length : 0,
1389
- lastRegisteredAt: new Date().toISOString(),
1390
- lastRegisteredAtMs: Date.now()
1391
- };
1392
- writeListenerRegistrationState(state);
1393
- }
1394
-
1395
- async function registerCliListenerSession(session, options = {}) {
1396
- if (!session || !session.token) {
1397
- return null;
1398
- }
1399
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
1400
- const payload = listenerRegistrationPayload(session, options);
1401
- if (!shouldRegisterCliListener(session, payload, options)) {
1402
- return { success: true, skipped: true, reason: 'recently_registered' };
1403
- }
1404
- const api = buildAxios(baseUrl, session.token, parsePositiveInt(options.timeoutMs, 5000));
1405
- const response = await api.post('/cli/register', payload);
1406
- rememberCliListenerRegistration(session, payload);
1407
- return response.data;
1408
- }
1409
-
1410
- async function bestEffortRegisterCliListeners(options = {}) {
1411
- const sessions = listSessionsFromStore(readAllSessions()).filter((session) => session && session.token);
1412
- if (!sessions.length) {
1413
- return [];
1414
- }
1415
- const results = await Promise.all(sessions.map(async (session) => {
1416
- try {
1417
- const data = await registerCliListenerSession(session, options);
1418
- return { success: true, accountId: session.accountId, baseUrl: session.baseUrl, data };
1419
- } catch (err) {
1420
- appendGlobalActivityLog('listener', 'registration.failed', {
1421
- accountId: session.accountId,
1422
- baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
1423
- error: describeError(err)
1424
- }, 'error');
1425
- return { success: false, accountId: session.accountId, baseUrl: session.baseUrl, error: describeError(err) };
1426
- }
1427
- }));
1428
- return results;
1429
- }
1430
-
1431
1278
  function resolvePreferredAccountId(cwd, flags, session) {
1432
1279
  // Explicit --account-id wins over the repo's account-info.json so users can
1433
1280
  // target a child account (e.g. a customer) from a parent-account repo.
@@ -1791,7 +1638,16 @@ function describeError(err) {
1791
1638
  if (responseData.message) {
1792
1639
  parts.push(String(responseData.message));
1793
1640
  } else if (typeof responseData === 'string') {
1794
- 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
+ }
1795
1651
  }
1796
1652
  }
1797
1653
  if (err.message) {
@@ -1842,7 +1698,7 @@ function isLikelySandboxNetworkError(err) {
1842
1698
  function sandboxNetworkHint(err) {
1843
1699
  if (!isLikelySandboxNetworkError(err)) return '';
1844
1700
  const baseUrl = err && err.config && err.config.baseURL ? err.config.baseURL : DEFAULT_BASE_URL;
1845
- 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.';
1846
1702
  }
1847
1703
 
1848
1704
  function saveToolsSnapshot(cwd, data) {
@@ -2142,10 +1998,6 @@ async function authCommand(flags) {
2142
1998
  });
2143
1999
  console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
2144
2000
  console.log('Data mode:', session.dataMode);
2145
- try {
2146
- await bestEffortRegisterCliListeners({ cwd, reason: 'auth' });
2147
- } catch (_) { /* best-effort */ }
2148
-
2149
2001
  try {
2150
2002
  const branchName = currentBranch(cwd);
2151
2003
  const toolsData = await loggedPost(api, cwd, '/cli/tools', {
@@ -4189,6 +4041,10 @@ let supportTicketSnapshotCache = {
4189
4041
  fetchedAtMs: 0,
4190
4042
  value: null
4191
4043
  };
4044
+ let agentSnapshotCache = {
4045
+ fetchedAtMs: 0,
4046
+ value: null
4047
+ };
4192
4048
 
4193
4049
  function listenerSessionEntriesFromStore(store) {
4194
4050
  return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
@@ -4421,18 +4277,18 @@ function slugify(value) {
4421
4277
 
4422
4278
  function detectIssueSignals(repoEntries, globalFiles) {
4423
4279
  const issues = [];
4424
- const tmuxLog = (globalFiles || []).find((file) => file.label === 'tmux-activity.log (tail)');
4425
- if (tmuxLog && tmuxLog.content) {
4426
- 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
4427
4283
  .split('\n')
4428
4284
  .filter((line) => /\[error\]/i.test(line))
4429
4285
  .slice(-8);
4430
4286
  if (errorLines.length) {
4431
4287
  issues.push({
4432
- id: 'tmux-activity-errors',
4288
+ id: 'activity-errors',
4433
4289
  severity: 'warn',
4434
4290
  scope: 'global',
4435
- title: 'Recent service or tmux errors detected',
4291
+ title: 'Recent service errors detected',
4436
4292
  summary: 'The global remits-cli activity log contains recent error-level events.',
4437
4293
  evidence: errorLines,
4438
4294
  recommendation: 'Review the activity tail and export an escalation bundle if this reflects unexpected platform or dispatch behavior.'
@@ -4556,11 +4412,10 @@ function exportIssueBundle(snapshot) {
4556
4412
  service: snapshot.service,
4557
4413
  discovery: snapshot.discovery,
4558
4414
  issueCount: (snapshot.issues || []).length,
4559
- tmux: {
4560
- available: snapshot.tmux && snapshot.tmux.available,
4561
- sessionExists: snapshot.tmux && snapshot.tmux.sessionExists,
4562
- sessionName: snapshot.tmux && snapshot.tmux.sessionName,
4563
- 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) || []
4564
4419
  },
4565
4420
  repos: (snapshot.repos || []).map((repo) => ({
4566
4421
  accountId: repo.accountId,
@@ -4576,10 +4431,9 @@ function exportIssueBundle(snapshot) {
4576
4431
  copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
4577
4432
  copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
4578
4433
  copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
4579
- copyFileIfExists(LISTENER_REGISTRATION_STATE_FILE, path.join(globalDir, 'listener-registration-state.json'));
4434
+ copyFileIfExists(AGENT_STATE_FILE, path.join(globalDir, 'agents.json'));
4580
4435
  copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
4581
- copyFileIfExists(DISPATCH_STATE_FILE, path.join(globalDir, 'dispatch-panes.json'));
4582
- copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
4436
+ copyFileIfExists(ACTIVITY_LOG_FILE, path.join(globalDir, 'activity.log'));
4583
4437
 
4584
4438
  const reposDir = path.join(bundleDir, 'repos');
4585
4439
  ensureDir(reposDir);
@@ -4618,10 +4472,9 @@ function buildGlobalFileSnapshot() {
4618
4472
  { label: 'config.json', path: CONFIG_FILE, mode: 'json' },
4619
4473
  { label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
4620
4474
  { label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
4621
- { label: 'listener-registration-state.json', path: LISTENER_REGISTRATION_STATE_FILE, mode: 'json' },
4622
- { label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
4475
+ { label: 'agents.json', path: AGENT_STATE_FILE, mode: 'json' },
4623
4476
  { label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
4624
- { label: 'tmux-activity.log (tail)', path: TMUX_ACTIVITY_LOG_FILE, mode: 'tail' }
4477
+ { label: 'activity.log (tail)', path: ACTIVITY_LOG_FILE, mode: 'tail' }
4625
4478
  ];
4626
4479
 
4627
4480
  return files.map((file) => {
@@ -4689,6 +4542,9 @@ function compactSupportTicketRow(row) {
4689
4542
  type: row.type || null,
4690
4543
  source: row.source || null,
4691
4544
  assignedTo: row.assignedTo || null,
4545
+ routedAgentId: row.routedAgentId || null,
4546
+ routedAgentLabel: row.routedAgentLabel || null,
4547
+ routedAt: row.routedAt || null,
4692
4548
  implementationAccountId: row.implementationAccountId || null,
4693
4549
  implementationAccountName: row.implementationAccountName || null,
4694
4550
  affectedComponent: row.affectedComponent || null,
@@ -4698,48 +4554,126 @@ function compactSupportTicketRow(row) {
4698
4554
  };
4699
4555
  }
4700
4556
 
4701
- function supportTicketQueueInput(session) {
4702
- return {
4703
- 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', {
4704
4569
  accountId: Number(session.accountId),
4705
4570
  includeChildren: true,
4706
- includeRoot: true,
4707
4571
  sortBy: 'triage',
4708
4572
  limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
4709
- };
4710
- }
4711
-
4712
- async function fetchSupportTicketQueueForSession(session) {
4713
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4714
- const api = buildAxios(baseUrl, session.token, 12000);
4715
- const response = await api.post('/cli/tool', {
4716
- token: session.token,
4717
- accountId: Number(session.accountId),
4718
- branchName: '',
4719
- dataMode: 'prod',
4720
- name: 'mcp_support_ticket_queue',
4721
- callId: 'dashboard-support-ticket-queue-' + crypto.randomUUID(),
4722
- input: supportTicketQueueInput(session)
4723
4573
  }).then((r) => r.data);
4724
4574
 
4725
- const result = response && response.result ? response.result : {};
4726
- const tickets = Array.isArray(result.tickets)
4575
+ const tickets = Array.isArray(result && result.tickets)
4727
4576
  ? result.tickets.map(compactSupportTicketRow).filter(Boolean)
4728
4577
  : [];
4729
4578
  return {
4730
- success: response && response.success !== false && response.toolSuccess !== false && result.success !== false,
4579
+ success: !!(result && result.success !== false),
4731
4580
  baseUrl,
4732
4581
  accountId: Number(session.accountId),
4733
4582
  userId: session.user && session.user.id ? session.user.id : null,
4734
- dataMode: result.dataMode || response.dataMode || 'prod',
4735
- totalCount: result.totalCount || tickets.length,
4736
- count: result.count || tickets.length,
4737
- counts: result.counts || {},
4738
- 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),
4739
4588
  tickets,
4740
- searchedAccounts: result.searchedAccounts || [],
4741
- 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
4742
4674
  };
4675
+ agentSnapshotCache = { fetchedAtMs: nowMs, value };
4676
+ return value;
4743
4677
  }
4744
4678
 
4745
4679
  async function collectSupportTicketSnapshot() {
@@ -4797,6 +4731,13 @@ async function collectSupportTicketSnapshot() {
4797
4731
  refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4798
4732
  queueCount: queues.length,
4799
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
+ }, {}),
4800
4741
  tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
4801
4742
  queues,
4802
4743
  errors
@@ -4817,29 +4758,6 @@ async function collectDashboardSnapshot() {
4817
4758
  const globalFiles = buildGlobalFileSnapshot();
4818
4759
  const issues = detectIssueSignals(repoEntries, globalFiles);
4819
4760
 
4820
- const paneState = fs.existsSync(DISPATCH_STATE_FILE) ? readJsonFilePretty(DISPATCH_STATE_FILE) : null;
4821
- const livePanes = [];
4822
- try {
4823
- if (isTmuxAvailable() && tmuxSessionExists()) {
4824
- const output = execSync(
4825
- '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}'),
4826
- { stdio: ['ignore', 'pipe', 'ignore'] }
4827
- ).toString();
4828
- for (const line of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
4829
- const parts = line.split('\t');
4830
- livePanes.push({
4831
- session: parts[0] || null,
4832
- window: parts[1] || null,
4833
- paneId: parts[2] || null,
4834
- currentPath: parts[3] || null,
4835
- dead: parts[4] === '1'
4836
- });
4837
- }
4838
- }
4839
- } catch (_) {
4840
- // Dashboard should remain readable even if tmux inspection fails.
4841
- }
4842
-
4843
4761
  return {
4844
4762
  generatedAt: new Date().toISOString(),
4845
4763
  service: {
@@ -4851,14 +4769,7 @@ async function collectDashboardSnapshot() {
4851
4769
  websocket: {
4852
4770
  connections: runtimeState.websocket
4853
4771
  },
4854
- tmux: {
4855
- available: isTmuxAvailable(),
4856
- terminalLaunchSupported: canLaunchTerminalForTmux(),
4857
- sessionName: TMUX_SESSION_NAME,
4858
- sessionExists: tmuxSessionExists(),
4859
- trackedPanes: paneState,
4860
- livePanes
4861
- },
4772
+ agents: await collectAgentSnapshot(),
4862
4773
  discovery: runtimeState.discovery,
4863
4774
  globalFiles,
4864
4775
  repos: repoEntries,
@@ -4904,9 +4815,7 @@ function renderDashboardHtml(snapshot) {
4904
4815
  '<button class="action-btn" data-action="export-issues">Export Issue Bundle</button>' +
4905
4816
  '<button class="action-btn" data-action="rescan-repos">Rescan Account Repos</button>' +
4906
4817
  '<button class="action-btn" data-action="reconnect-websockets">Reconnect Websockets</button>' +
4907
- '<button class="action-btn" data-action="open-tmux">Open Tmux Terminal</button>' +
4908
- '<button class="action-btn" data-action="start-tmux">Ensure Tmux Session</button>' +
4909
- '<button class="action-btn" data-action="stop-tmux">Kill Tmux Session</button>' +
4818
+ '<button class="action-btn" data-action="refresh-agents">Refresh Agents</button>' +
4910
4819
  '</div>' +
4911
4820
  '</section>' +
4912
4821
  '<div class="section-grid">' +
@@ -4919,6 +4828,9 @@ function renderDashboardHtml(snapshot) {
4919
4828
  '<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
4920
4829
  '</section>' +
4921
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>' +
4922
4834
  '<div class="section-grid">' +
4923
4835
  '<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
4924
4836
  '</div>' +
@@ -4927,7 +4839,7 @@ function renderDashboardHtml(snapshot) {
4927
4839
  '</div>' +
4928
4840
  '<div class="section-grid">' +
4929
4841
  '<section class="panel"><h2>Websocket Connections</h2><div id="websocket-section" class="accordion"></div></section>' +
4930
- '<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>' +
4931
4843
  '</div>' +
4932
4844
  '<div class="section-grid">' +
4933
4845
  '<section class="panel"><h2>Global Files</h2><div id="global-files" class="accordion"></div></section>' +
@@ -4952,36 +4864,39 @@ function renderDashboardHtml(snapshot) {
4952
4864
  'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
4953
4865
  'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
4954
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>\';}' +
4955
- '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=[' +
4956
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")},' +
4957
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")},' +
4958
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)"},' +
4959
- '{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"},' +
4960
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"},' +
4961
4873
  '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
4962
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("");}' +
4963
- '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=[' +
4964
4876
  'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
4965
4877
  'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
4966
4878
  'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
4967
4879
  'summaryItem("Connections",String(ws.length)),' +
4968
4880
  'summaryItem("Topics",String(topics)),' +
4969
4881
  'summaryItem("Open Tickets",String(state.supportTickets&&state.supportTickets.totalCount||0)),' +
4970
- 'summaryItem("Tracked Panes",String(tracked))' +
4882
+ 'summaryItem("Available Agents",String(agentList.length)),' +
4883
+ 'summaryItem("Routed Tickets",String(ticketCounts.routed||0))' +
4971
4884
  '].join("");}' +
4972
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>\':"");}' +
4973
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("");}' +
4974
- '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;}' +
4975
- '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>\';}' +
4976
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("");}' +
4977
- '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);}}' +
4978
- '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("");}' +
4979
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("");}' +
4980
- '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);}' +
4981
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();}' +
4982
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);}' +
4983
- '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;}}' +
4984
- '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);});' +
4985
4900
  'renderAll(dashboardState);' +
4986
4901
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
4987
4902
  '})();' +
@@ -5068,15 +4983,19 @@ function startDashboardServer(preferredPort) {
5068
4983
  } else if (action === 'rescan-repos') {
5069
4984
  recordDiscoverySummary(discoverAccountRepos());
5070
4985
  supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5071
- await bestEffortRegisterCliListeners({ reason: 'dashboard.rescan' });
5072
- } else if (action === 'open-tmux') {
5073
- openTmuxSessionForUser();
5074
- } else if (action === 'start-tmux') {
5075
- ensureTmuxSession(os.homedir());
5076
- } else if (action === 'stop-tmux') {
5077
- killTmuxSession();
4986
+ } else if (action === 'refresh-agents') {
4987
+ agentSnapshotCache = { fetchedAtMs: 0, value: null };
4988
+ supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5078
4989
  } else if (action === 'reconnect-websockets') {
5079
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 };
5080
4999
  }
5081
5000
  if (wantsJson) {
5082
5001
  res.statusCode = 200;
@@ -5138,518 +5057,110 @@ function startDashboardServer(preferredPort) {
5138
5057
  });
5139
5058
  }
5140
5059
 
5141
- // --- Agent CLI dispatch via tmux ---
5142
- const TMUX_SESSION_NAME = 'remits-listener';
5143
- const DISPATCH_STATE_FILE = path.join(SESSION_DIR, 'dispatch-panes.json');
5144
-
5145
- // In-memory map of support-ticket routing key -> tmux pane ID (persisted for crash recovery)
5146
- let paneMap = {};
5147
-
5148
- function dispatchTicketId(message) {
5149
- if (!message || typeof message !== 'object') return null;
5150
- if (message.ticketId !== undefined && message.ticketId !== null && String(message.ticketId).trim()) {
5151
- return String(message.ticketId).trim();
5060
+ function publishMissedMessageRequest(client, baseUrl, userId) {
5061
+ if (!userId) {
5062
+ return;
5152
5063
  }
5153
- 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
+ });
5154
5077
  }
5155
5078
 
5156
- function dispatchRoutingKey(message) {
5157
- const ticketId = dispatchTicketId(message);
5158
- if (ticketId) return 'ticket:' + ticketId;
5159
- if (message && message.taskId !== undefined && message.taskId !== null && String(message.taskId).trim()) {
5160
- return 'task:' + String(message.taskId).trim();
5079
+ function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
5080
+ if (!messageId) {
5081
+ return;
5161
5082
  }
5162
- 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
+ });
5163
5091
  }
5164
5092
 
5165
- function loadPaneMap() {
5166
- if (fs.existsSync(DISPATCH_STATE_FILE)) {
5167
- try {
5168
- paneMap = JSON.parse(fs.readFileSync(DISPATCH_STATE_FILE, 'utf8'));
5169
- } catch (_) {
5170
- paneMap = {};
5171
- }
5172
- }
5173
- }
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;
5174
5097
 
5175
- function savePaneMap() {
5176
- ensureSessionDir();
5177
- atomicWriteFile(DISPATCH_STATE_FILE, JSON.stringify(paneMap, null, 2));
5178
- }
5098
+ recordWebsocketMessage(baseUrl, topic, data, {
5099
+ source,
5100
+ duplicate,
5101
+ accountIds
5102
+ });
5179
5103
 
5180
- function isTmuxAvailable() {
5181
- try {
5182
- execSync('which tmux', { stdio: 'ignore' });
5183
- return true;
5184
- } catch (_) {
5185
- return false;
5104
+ if (messageId) {
5105
+ acknowledgeWebsocketMessage(client, messageId, userIds);
5186
5106
  }
5187
- }
5188
5107
 
5189
- function tmuxSessionExists() {
5190
- try {
5191
- execSync('tmux has-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
5192
- return true;
5193
- } catch (_) {
5194
- return false;
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;
5195
5112
  }
5196
- }
5197
5113
 
5198
- function tmuxPaneIsAlive(paneId) {
5199
- try {
5200
- // Check both that the pane exists AND that it's not dead
5201
- const output = execSync(
5202
- 'tmux list-panes -a -F ' + shellQuote('#{pane_id} #{pane_dead}'),
5203
- { stdio: ['ignore', 'pipe', 'ignore'] }
5204
- ).toString();
5205
- const lines = output.split('\n').map((l) => l.trim()).filter(Boolean);
5206
- for (const line of lines) {
5207
- const [id, dead] = line.split(' ');
5208
- if (id === paneId) {
5209
- return dead !== '1';
5210
- }
5211
- }
5212
- return false; // pane not found at all
5213
- } catch (_) {
5214
- return false;
5114
+ if (duplicate) {
5115
+ appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
5116
+ baseUrl,
5117
+ topic,
5118
+ messageId,
5119
+ source
5120
+ });
5121
+ updateRuntimeServiceState();
5122
+ return;
5215
5123
  }
5216
- }
5217
-
5218
- function killPane(paneId) {
5219
- try {
5220
- execSync('tmux kill-pane -t ' + shellQuote(paneId), { stdio: 'ignore' });
5221
- appendGlobalActivityLog('tmux', 'pane.killed', { paneId });
5222
- } catch (_) {}
5223
- }
5224
-
5225
- function killTmuxSession() {
5226
- if (!tmuxSessionExists()) return;
5227
- try {
5228
- execSync('tmux kill-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
5229
- appendGlobalActivityLog('tmux', 'session.killed', { sessionName: TMUX_SESSION_NAME });
5230
- } catch (_) {}
5231
- }
5232
5124
 
5233
- function configureTmuxSession() {
5234
- const commands = [
5235
- 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' mouse on',
5236
- 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' history-limit 100000',
5237
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' remain-on-exit on',
5238
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' scroll-on-clear on',
5239
- 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' automatic-rename off'
5240
- ];
5241
- for (const command of commands) {
5242
- try {
5243
- execSync(command, { stdio: 'ignore' });
5244
- } catch (_) {}
5125
+ if (messageId) {
5126
+ rememberProcessedMessageId(messageId);
5245
5127
  }
5246
- }
5247
5128
 
5248
- function ensureTmuxSession(accountDir) {
5249
- if (!isTmuxAvailable()) {
5250
- appendGlobalActivityLog('tmux', 'session.create_skipped.tmux_unavailable', { accountDir }, 'error');
5251
- return false;
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;
5136
+ }
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 };
5252
5161
  }
5253
- if (tmuxSessionExists()) return;
5254
- execSync(
5255
- 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(accountDir),
5256
- { stdio: 'ignore' }
5257
- );
5258
- configureTmuxSession();
5259
- console.log('[listener] Created tmux session:', TMUX_SESSION_NAME);
5260
- appendGlobalActivityLog('tmux', 'session.created', {
5261
- sessionName: TMUX_SESSION_NAME,
5262
- accountDir
5263
- });
5264
- return true;
5265
- }
5266
5162
 
5267
- function canLaunchTerminalForTmux() {
5268
- return process.platform === 'darwin';
5269
- }
5270
-
5271
- function openTmuxSessionForUser() {
5272
- if (!isTmuxAvailable()) {
5273
- throw new Error('tmux is not installed');
5274
- }
5275
-
5276
- if (!tmuxSessionExists()) {
5277
- ensureTmuxSession(os.homedir());
5278
- }
5279
-
5280
- const attachCommand = 'tmux attach -t ' + TMUX_SESSION_NAME + ' || tmux new-session -A -s ' + TMUX_SESSION_NAME;
5281
-
5282
- if (process.platform === 'darwin') {
5283
- const osaScript = [
5284
- 'tell application "Terminal"',
5285
- 'activate',
5286
- 'do script ' + JSON.stringify(attachCommand),
5287
- 'end tell'
5288
- ].join('\n');
5289
- execSync('osascript -e ' + shellQuote(osaScript), { stdio: 'ignore' });
5290
- appendGlobalActivityLog('tmux', 'terminal.opened', {
5291
- sessionName: TMUX_SESSION_NAME,
5292
- command: attachCommand
5293
- });
5294
- return {
5295
- success: true,
5296
- method: 'terminal',
5297
- command: attachCommand
5298
- };
5299
- }
5300
-
5301
- throw new Error('Opening a terminal window is not supported on this platform yet. Run `' + attachCommand + '` manually.');
5302
- }
5303
-
5304
- function dispatchWindowName(accountId, routingKey) {
5305
- const base = routingKey || ('account:' + accountId) || 'dispatch';
5306
- return base
5307
- .replace(/[^a-zA-Z0-9:_-]+/g, '-')
5308
- .slice(0, 40) || 'dispatch';
5309
- }
5310
-
5311
- function resolveAgentCommand(agent, prompt) {
5312
- const escapedPrompt = prompt.replace(/'/g, "'\"'\"'");
5313
- // All agents are launched in interactive mode so the user can watch and interact.
5314
- // claude: positional arg starts interactive session with initial prompt
5315
- // codex: positional arg starts interactive session with initial prompt
5316
- // gemini: positional arg starts interactive session with initial prompt
5317
- switch (agent) {
5318
- case 'claude':
5319
- return "claude '" + escapedPrompt + "'";
5320
- case 'codex':
5321
- return "codex '" + escapedPrompt + "'";
5322
- case 'gemini':
5323
- return "gemini '" + escapedPrompt + "'";
5324
- default:
5325
- return "claude '" + escapedPrompt + "'";
5326
- }
5327
- }
5328
-
5329
- function resolveAccountDir(accountId) {
5330
- if (!fs.existsSync(ACCOUNT_REPO_INDEX_FILE)) return null;
5331
- try {
5332
- const index = JSON.parse(fs.readFileSync(ACCOUNT_REPO_INDEX_FILE, 'utf8'));
5333
- const entry = index[String(accountId)];
5334
- if (entry && entry.directory && fs.existsSync(entry.directory)) {
5335
- return entry.directory;
5336
- }
5337
- } catch (_) {}
5338
- return null;
5339
- }
5340
-
5341
- function normalizeDispatchAccountId(value) {
5342
- if (value === undefined || value === null) return null;
5343
- const trimmed = String(value).trim();
5344
- return trimmed ? trimmed : null;
5345
- }
5346
-
5347
- function resolveDispatchAccountId(message, fallbackAccountId) {
5348
- const candidates = [
5349
- normalizeDispatchAccountId(message && message.implementationAccountId),
5350
- normalizeDispatchAccountId(message && message.accountId),
5351
- normalizeDispatchAccountId(fallbackAccountId)
5352
- ].filter(Boolean);
5353
-
5354
- const uniqueCandidates = [];
5355
- for (const candidate of candidates) {
5356
- if (!uniqueCandidates.includes(candidate)) {
5357
- uniqueCandidates.push(candidate);
5358
- }
5359
- }
5360
-
5361
- for (const candidate of uniqueCandidates) {
5362
- if (resolveAccountDir(candidate)) {
5363
- return candidate;
5364
- }
5365
- }
5366
-
5367
- return uniqueCandidates[0] || null;
5368
- }
5369
-
5370
- function sendKeysToPane(paneId, text) {
5371
- // Paste through a tmux buffer so multiline follow-ups are injected reliably
5372
- // into interactive agent UIs, then submit them explicitly.
5373
- const bufferName = 'remits-cli-' + crypto.randomUUID();
5374
- try {
5375
- execSync(
5376
- 'tmux load-buffer -b ' + shellQuote(bufferName) + ' -',
5377
- { input: text, stdio: ['pipe', 'ignore', 'ignore'] }
5378
- );
5379
- execSync(
5380
- 'tmux paste-buffer -d -b ' + shellQuote(bufferName) + ' -t ' + shellQuote(paneId),
5381
- { stdio: 'ignore' }
5382
- );
5383
- execSync(
5384
- 'tmux send-keys -t ' + shellQuote(paneId) + ' Enter',
5385
- { stdio: 'ignore' }
5386
- );
5387
- } finally {
5388
- try {
5389
- execSync('tmux delete-buffer -b ' + shellQuote(bufferName), { stdio: 'ignore' });
5390
- } catch (_) {}
5391
- }
5392
- }
5393
-
5394
- function buildDispatchPrompt(message, options) {
5395
- const dispatchOptions = options && typeof options === 'object' ? options : {};
5396
- const textPrompt = message && (message.prompt || message.message);
5397
- const context = message && typeof message === 'object' ? Object.assign({}, message) : {};
5398
- delete context.prompt;
5399
- delete context.message;
5400
-
5401
- const instructionLines = [
5402
- 'This message was delivered by the remits-cli tool from an inbound platform message.',
5403
- 'When triaging and handling this request, load and use the remits-cli skill.'
5404
- ];
5405
-
5406
- if (dispatchOptions.noAccountDir === true) {
5407
- instructionLines.push('remits-cli dispatch status: no_account_dir.');
5408
- 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.');
5409
- }
5410
-
5411
- if (textPrompt) {
5412
- instructionLines.push('');
5413
- instructionLines.push('Inbound message:');
5414
- instructionLines.push(textPrompt);
5415
- }
5416
-
5417
- if (Object.keys(context).length) {
5418
- instructionLines.push('');
5419
- instructionLines.push('Dispatch context:');
5420
- instructionLines.push('```json');
5421
- instructionLines.push(JSON.stringify(context, null, 2));
5422
- instructionLines.push('```');
5423
- }
5424
-
5425
- return instructionLines.join('\n');
5426
- }
5427
-
5428
- function dispatchToAgent(accountId, message) {
5429
- const accountDir = resolveAccountDir(accountId);
5430
-
5431
- if (!isTmuxAvailable()) {
5432
- console.error('[listener] tmux is not installed. Cannot dispatch agent session.');
5433
- appendGlobalActivityLog('tmux', 'dispatch.skipped.tmux_unavailable', {
5434
- accountId,
5435
- accountDir,
5436
- message: summarizeDispatchMessage(message)
5437
- }, 'error');
5438
- return;
5439
- }
5440
-
5441
- const ticketId = dispatchTicketId(message);
5442
- const routingKey = dispatchRoutingKey(message);
5443
- const prompt = buildDispatchPrompt(message, {
5444
- noAccountDir: !accountDir
5445
- });
5446
-
5447
- if (!accountDir) {
5448
- console.error('[listener] No known directory for account', accountId, '- dispatching anyway');
5449
- appendGlobalActivityLog('tmux', 'dispatch.no_account_dir', {
5450
- accountId,
5451
- message: summarizeDispatchMessage(message)
5452
- }, 'error');
5453
- }
5454
-
5455
- // If this ticket already has a tracked pane, check its state
5456
- if (routingKey && paneMap[routingKey]) {
5457
- const existingPaneId = paneMap[routingKey];
5458
- if (tmuxPaneIsAlive(existingPaneId)) {
5459
- // Pane is alive, so deliver the follow-up to the existing ticket workstream
5460
- console.log('[listener] Delivering follow-up for', routingKey, 'to existing pane', existingPaneId);
5461
- appendGlobalActivityLog('tmux', 'dispatch.follow_up', {
5462
- accountId,
5463
- accountDir,
5464
- ticketId,
5465
- routingKey,
5466
- paneId: existingPaneId,
5467
- message: summarizeDispatchMessage(message)
5468
- });
5469
- try {
5470
- sendKeysToPane(existingPaneId, prompt);
5471
- return;
5472
- } catch (err) {
5473
- console.error('[listener] Failed to send to pane', existingPaneId, ':', err.message, '- creating new pane');
5474
- appendGlobalActivityLog('tmux', 'dispatch.follow_up_failed', {
5475
- accountId,
5476
- accountDir,
5477
- ticketId,
5478
- routingKey,
5479
- paneId: existingPaneId,
5480
- error: err.message,
5481
- message: summarizeDispatchMessage(message)
5482
- }, 'error');
5483
- }
5484
- }
5485
- // Pane is dead or gone, so replace it for this ticket/task routing key.
5486
- console.log('[listener] Pane', existingPaneId, 'for', routingKey, 'is dead or gone - replacing');
5487
- appendGlobalActivityLog('tmux', 'dispatch.pane_replaced', {
5488
- accountId,
5489
- accountDir,
5490
- ticketId,
5491
- routingKey,
5492
- paneId: existingPaneId,
5493
- message: summarizeDispatchMessage(message)
5494
- });
5495
- killPane(existingPaneId);
5496
- delete paneMap[routingKey];
5497
- savePaneMap();
5498
- }
5499
-
5500
- // Create a new pane for this task
5501
- try {
5502
- const paneDir = accountDir || os.homedir();
5503
- ensureTmuxSession(paneDir);
5504
-
5505
- const agent = getPreferredAgent();
5506
- const agentCmd = resolveAgentCommand(agent, prompt);
5507
- const windowName = dispatchWindowName(accountId, routingKey);
5508
-
5509
- // Wrap in a login shell so the pane inherits the user's full PATH
5510
- // (the daemonized listener process has a minimal environment)
5511
- const userShell = process.env.SHELL || '/bin/zsh';
5512
- const loginCmd = userShell + ' -l -c ' + shellQuote(agentCmd);
5513
-
5514
- // Create a dedicated window for this workstream so full-screen agent UIs
5515
- // remain readable and easy to inspect.
5516
- const paneInfo = execSync(
5517
- 'tmux new-window -t ' + shellQuote(TMUX_SESSION_NAME) +
5518
- ' -n ' + shellQuote(windowName) +
5519
- ' -c ' + shellQuote(paneDir) +
5520
- ' -P -F ' + shellQuote('#{pane_id}') +
5521
- ' ' + shellQuote(loginCmd),
5522
- { stdio: ['ignore', 'pipe', 'ignore'] }
5523
- ).toString().trim();
5524
-
5525
- console.log('[listener] Dispatched', routingKey || '(untracked message)', 'to', agent, 'in pane', paneInfo, 'at', accountDir);
5526
- appendGlobalActivityLog('tmux', 'dispatch.created', {
5527
- accountId,
5528
- accountDir: paneDir,
5529
- resolvedAccountDir: accountDir,
5530
- ticketId,
5531
- routingKey,
5532
- paneId: paneInfo,
5533
- agent,
5534
- message: summarizeDispatchMessage(message)
5535
- });
5536
-
5537
- if (routingKey) {
5538
- paneMap[routingKey] = paneInfo;
5539
- savePaneMap();
5540
- }
5541
- } catch (err) {
5542
- console.error('[listener] Failed to create tmux pane:', err.message);
5543
- appendGlobalActivityLog('tmux', 'dispatch.create_failed', {
5544
- accountId,
5545
- accountDir,
5546
- ticketId,
5547
- routingKey,
5548
- error: err.message,
5549
- agent: getPreferredAgent(),
5550
- message: summarizeDispatchMessage(message)
5551
- }, 'error');
5552
- }
5553
- }
5554
-
5555
- function publishMissedMessageRequest(client, baseUrl, userId) {
5556
- if (!userId) {
5557
- return;
5558
- }
5559
- const since = lastMessageTimestampForScope(baseUrl, userId);
5560
- client.publish({
5561
- destination: '/app/missed-messages',
5562
- body: JSON.stringify({
5563
- userId: String(userId),
5564
- since
5565
- })
5566
- });
5567
- appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
5568
- baseUrl,
5569
- userId,
5570
- since
5571
- });
5572
- }
5573
-
5574
- function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
5575
- if (!messageId) {
5576
- return;
5577
- }
5578
- const primaryUserId = userIds.find(Boolean);
5579
- const body = primaryUserId
5580
- ? { messageId, userId: String(primaryUserId) }
5581
- : { messageId };
5582
- client.publish({
5583
- destination: '/app/ack',
5584
- body: JSON.stringify(body)
5585
- });
5586
- }
5587
-
5588
- function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
5589
- const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
5590
- const messageId = data.messageId ? String(data.messageId) : null;
5591
- const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
5592
-
5593
- recordWebsocketMessage(baseUrl, topic, data, {
5594
- source,
5595
- duplicate,
5596
- accountIds
5597
- });
5598
-
5599
- if (messageId) {
5600
- acknowledgeWebsocketMessage(client, messageId, userIds);
5601
- }
5602
-
5603
- updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
5604
- runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
5605
- if (runtimeState.websocket[baseUrl].topics[topic]) {
5606
- runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
5607
- }
5608
-
5609
- if (duplicate) {
5610
- appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
5611
- baseUrl,
5612
- topic,
5613
- messageId,
5614
- source
5615
- });
5616
- updateRuntimeServiceState();
5617
- return;
5618
- }
5619
-
5620
- if (messageId) {
5621
- rememberProcessedMessageId(messageId);
5622
- }
5623
-
5624
- if (data.type === 'TestSuite') {
5625
- const payload = data.payload || {};
5626
- const payloadTaskId = payload.taskId || null;
5627
- const currentTestTaskId = runtimeState.currentTestTaskId || null;
5628
- if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
5629
- updateRuntimeServiceState();
5630
- return;
5631
- }
5632
- if (payload.tests || payload.name || payload.total != null) {
5633
- const acctId = payload.accountId || accountIds[0];
5634
- console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
5635
- }
5636
- } else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
5637
- const payload = data.payload || data;
5638
- const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
5639
- console.log('[ws][listener][account:' + dispatchAccountId + ']', JSON.stringify(payload));
5640
- appendGlobalActivityLog('tmux', 'listener_delivery.received', {
5641
- baseUrl,
5642
- topic,
5643
- accountId: dispatchAccountId,
5644
- messageType: data.type,
5645
- source,
5646
- messageId,
5647
- message: summarizeDispatchMessage(payload)
5648
- });
5649
- dispatchToAgent(dispatchAccountId, payload);
5650
- }
5651
-
5652
- updateRuntimeServiceState();
5163
+ updateRuntimeServiceState();
5653
5164
  }
5654
5165
 
5655
5166
  function createManagedWebsocketClient(baseUrl, urlSessions) {
@@ -5867,42 +5378,6 @@ async function replaceManagedWebsocketClients(entries, reason) {
5867
5378
  });
5868
5379
  }
5869
5380
 
5870
- function pruneDeadPaneMappings() {
5871
- const removed = [];
5872
- for (const [routingKey, paneId] of Object.entries(paneMap)) {
5873
- if (tmuxPaneIsAlive(paneId)) {
5874
- continue;
5875
- }
5876
- delete paneMap[routingKey];
5877
- removed.push({ routingKey, paneId });
5878
- }
5879
- if (removed.length) {
5880
- savePaneMap();
5881
- appendGlobalActivityLog('tmux', 'dispatch_state.pruned', {
5882
- removed
5883
- });
5884
- }
5885
- }
5886
-
5887
- function ensureTmuxSessionHealthy(defaultDir) {
5888
- if (!isTmuxAvailable()) {
5889
- return;
5890
- }
5891
- if (tmuxSessionExists()) {
5892
- pruneDeadPaneMappings();
5893
- return;
5894
- }
5895
- const hadPaneMappings = Object.keys(paneMap).length > 0;
5896
- if (hadPaneMappings) {
5897
- paneMap = {};
5898
- savePaneMap();
5899
- appendGlobalActivityLog('tmux', 'dispatch_state.cleared.missing_session', {
5900
- sessionName: TMUX_SESSION_NAME
5901
- }, 'error');
5902
- }
5903
- ensureTmuxSession(defaultDir || os.homedir());
5904
- }
5905
-
5906
5381
  async function runManagedWebsocketWatchdog() {
5907
5382
  const now = Date.now();
5908
5383
  for (const managedClient of managedClients) {
@@ -5952,6 +5427,602 @@ async function runManagedWebsocketWatchdog() {
5952
5427
  }
5953
5428
 
5954
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
+ function agentSessionFor(flags, entry) {
5621
+ const cwd = (entry && entry.cwd) || process.cwd();
5622
+ const context = resolveSessionContext(cwd, Object.assign({}, flags, {
5623
+ 'base-url': (flags && flags['base-url']) || (entry && entry.baseUrl) || undefined
5624
+ }));
5625
+ if (!context.session || !context.session.token) {
5626
+ throw new Error('No authenticated session. Run: remits-cli auth');
5627
+ }
5628
+ // resolveSessionContext names it `resolvedBaseUrl` and resolves no data lane, so normalize both here
5629
+ // rather than letting each caller re-derive them. Destructuring `{ baseUrl, dataMode }` off it yields
5630
+ // undefined silently, and the failure then surfaces far away as an axios "Invalid URL".
5631
+ return {
5632
+ session: context.session,
5633
+ accountId: context.accountId,
5634
+ baseUrl: normalizeBaseUrl(context.resolvedBaseUrl || (entry && entry.baseUrl) || DEFAULT_BASE_URL),
5635
+ dataMode: resolveDataMode(flags, context.session),
5636
+ sessionResolutionWarning: context.sessionResolutionWarning
5637
+ };
5638
+ }
5639
+
5640
+ /** Every account repo checked out on this machine, as registration claims. The platform verifies them. */
5641
+ function agentAccountClaims(cwd) {
5642
+ const claims = listAccountRepoEntries(readAccountRepoIndex()).map((entry) => ({
5643
+ accountId: Number(entry.accountId),
5644
+ name: entry.name || null,
5645
+ type: entry.type || null,
5646
+ platformAccountId: entry.platformAccountId || null,
5647
+ directory: entry.directory || null,
5648
+ routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
5649
+ ? 'self_and_descendants'
5650
+ : 'self',
5651
+ source: entry.source || 'repo'
5652
+ })).filter((entry) => Number.isFinite(entry.accountId) && entry.accountId > 0);
5653
+
5654
+ // The repo the command was run from leads, so a tab working one account is routed that account's
5655
+ // tickets first even when the machine has a dozen checkouts.
5656
+ try {
5657
+ const here = loadAccountId(cwd);
5658
+ const index = claims.findIndex((entry) => entry.accountId === Number(here));
5659
+ if (index > 0) {
5660
+ claims.unshift(claims.splice(index, 1)[0]);
5661
+ }
5662
+ } catch (_) { /* not in an account repo */ }
5663
+ return claims;
5664
+ }
5665
+
5666
+ async function agentRegisterCommand(flags, options = {}) {
5667
+ const cwd = process.cwd();
5668
+ try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
5669
+ try { discoverAccountRepos(); } catch (_) { /* best-effort */ }
5670
+
5671
+ const { session, baseUrl, dataMode } = agentSessionFor(flags, null);
5672
+ const anchor = resolveAgentAnchorPid();
5673
+ if (!anchor.pid) {
5674
+ throw new Error(
5675
+ 'Could not find a process to anchor this agent to, so registering would create an agent that ' +
5676
+ 'never goes offline. Run this from an interactive terminal, or set REMITS_AGENT_ANCHOR_PID to ' +
5677
+ 'the pid whose exit should end this agent session.');
5678
+ }
5679
+ const explicitId = (flags['agent-id'] || flags.agentId || process.env.REMITS_AGENT_ID || '').trim();
5680
+ // Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
5681
+ // to let an unrelated record be adopted whenever two registrations shared an anchor.
5682
+ const existingId = explicitId ||
5683
+ (listLocalAgents().find((entry) =>
5684
+ entry.cwd === cwd && entry.anchorPid === anchor.pid && entry.anchorSource === anchor.source) || {}).agentId ||
5685
+ null;
5686
+
5687
+ const api = buildAxios(baseUrl, session.token, 15000);
5688
+ const payload = {
5689
+ agentId: existingId || undefined,
5690
+ label: flags.label || agentLabelDefault(anchor, cwd),
5691
+ agentType: flags.agent || getPreferredAgent(),
5692
+ cliVersion: readCliVersion(),
5693
+ host: os.hostname(),
5694
+ pid: anchor.pid,
5695
+ cwd,
5696
+ dataMode,
5697
+ accountId: Number(session.accountId) || undefined,
5698
+ accounts: agentAccountClaims(cwd),
5699
+ state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
5700
+ activity: flags.activity || null,
5701
+ source: flags.source || 'repo'
5702
+ };
5703
+
5704
+ const response = await api.post('/cli/agentRegister', payload).then((r) => r.data);
5705
+ if (!response || response.success === false) {
5706
+ throw new Error((response && response.message) || 'Agent registration was refused.');
5707
+ }
5708
+
5709
+ const entry = rememberLocalAgent({
5710
+ agentId: response.agentId,
5711
+ label: payload.label,
5712
+ cwd,
5713
+ baseUrl,
5714
+ dataMode,
5715
+ accountId: Number(session.accountId) || null,
5716
+ anchorPid: anchor.pid,
5717
+ anchorSource: anchor.source,
5718
+ registeredAtMs: Date.now()
5719
+ });
5720
+
5721
+ // A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
5722
+ // re-registering, and the daemons multiply for as long as the platform stays unreachable.
5723
+ if (options.spawnHeartbeat !== false) {
5724
+ startAgentHeartbeatDaemon(entry);
5725
+ }
5726
+
5727
+ if (flagEnabled(flags.json)) {
5728
+ console.log(JSON.stringify({ ...response, local: entry }, null, 2));
5729
+ return;
5730
+ }
5731
+
5732
+ console.log('Registered agent ' + response.agentId);
5733
+ console.log(' label ' + payload.label);
5734
+ console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (production data)' : ''));
5735
+ console.log(' accounts ' + (response.accountIds || []).join(', '));
5736
+ console.log(' heartbeat every ' + Math.round(AGENT_HEARTBEAT_INTERVAL_MS / 1000) + 's, anchored to pid ' +
5737
+ anchor.pid + ' (' + anchor.source + ')');
5738
+ if ((response.skippedAccounts || []).length) {
5739
+ console.log(' refused ' + response.skippedAccounts
5740
+ .map((entry) => entry.accountId + ' (' + entry.reason + ')').join(', '));
5741
+ }
5742
+ console.log('');
5743
+ console.log('This session is now available for support tickets. Ask for work with:');
5744
+ console.log(' remits-cli agent work');
5745
+ }
5746
+
5747
+ function readCliVersion() {
5748
+ try {
5749
+ return require(path.join(__dirname, 'package.json')).version || null;
5750
+ } catch (_) {
5751
+ return null;
5752
+ }
5753
+ }
5754
+
5755
+ /**
5756
+ * Spawn the detached heartbeat that keeps this session visible.
5757
+ *
5758
+ * It is a separate process on purpose: the registering command must return immediately so the agent
5759
+ * can get on with its work, and presence must continue between commands — an agent that only
5760
+ * appeared online while it happened to be running remits-cli would be invisible for exactly the
5761
+ * period it was busy working a ticket.
5762
+ */
5763
+ function startAgentHeartbeatDaemon(entry) {
5764
+ const { spawn } = require('child_process');
5765
+ const child = spawn(process.execPath, [
5766
+ __filename, 'agent', 'heartbeat-daemon',
5767
+ '--agent-id', entry.agentId,
5768
+ '--anchor-pid', String(entry.anchorPid),
5769
+ '--base-url', entry.baseUrl,
5770
+ '--cwd', entry.cwd
5771
+ ], { detached: true, stdio: 'ignore', env: { ...process.env } });
5772
+ child.unref();
5773
+ appendGlobalActivityLog('agent', 'heartbeat.started', {
5774
+ agentId: entry.agentId,
5775
+ anchorPid: entry.anchorPid,
5776
+ heartbeatPid: child.pid
5777
+ });
5778
+ return child.pid;
5779
+ }
5780
+
5781
+ async function agentHeartbeatDaemonCommand(flags) {
5782
+ const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
5783
+ const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
5784
+ const cwd = flags.cwd || process.cwd();
5785
+ if (!agentId || !Number.isFinite(anchorPid)) {
5786
+ throw new Error('agent heartbeat-daemon requires --agent-id and --anchor-pid.');
5787
+ }
5788
+
5789
+ // The daemon runs detached, so its own process tree says nothing about the session it is watching.
5790
+ // Re-registration must reuse the anchor it was given, never re-resolve one.
5791
+ process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
5792
+
5793
+ const stop = async (reason) => {
5794
+ appendGlobalActivityLog('agent', 'heartbeat.stopped', { agentId, anchorPid, reason });
5795
+ forgetLocalAgent(agentId);
5796
+ try {
5797
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
5798
+ await buildAxios(context.baseUrl, context.session.token, 5000)
5799
+ .post('/cli/agentDeregister', { agentId });
5800
+ } catch (_) {
5801
+ // A courtesy goodbye. The registry TTL is the mechanism; this only makes it immediate.
5802
+ }
5803
+ process.exit(0);
5804
+ };
5805
+
5806
+ process.on('SIGTERM', () => { stop('signal'); });
5807
+ process.on('SIGINT', () => { stop('signal'); });
5808
+
5809
+ for (;;) {
5810
+ if (!processIsAlive(anchorPid)) {
5811
+ await stop('anchor_exited');
5812
+ return;
5813
+ }
5814
+ try {
5815
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
5816
+ const response = await buildAxios(context.baseUrl, context.session.token, 10000)
5817
+ .post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
5818
+ if (response && response.registered === false) {
5819
+ // The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
5820
+ // than heartbeating into a void — the tab is still very much alive.
5821
+ appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
5822
+ await agentRegisterCommand(
5823
+ { 'agent-id': agentId, 'base-url': context.baseUrl, json: 'true' },
5824
+ { spawnHeartbeat: false }
5825
+ ).catch((err) => {
5826
+ appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
5827
+ { agentId, error: describeError(err) }, 'error');
5828
+ });
5829
+ }
5830
+ } catch (err) {
5831
+ appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
5832
+ }
5833
+ await new Promise((resolve) => setTimeout(resolve, AGENT_HEARTBEAT_INTERVAL_MS));
5834
+ }
5835
+ }
5836
+
5837
+ async function agentStatusCommand(flags) {
5838
+ const agentId = resolveAgentId(flags);
5839
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
5840
+ const context = agentSessionFor(flags, local);
5841
+
5842
+ const update = { agentId };
5843
+ const positional = flags._ && flags._.length > 2 ? flags._.slice(2).join(' ') : null;
5844
+ const activity = flags.activity || positional;
5845
+ if (flags.state) {
5846
+ if (!AGENT_STATES.includes(String(flags.state))) {
5847
+ throw new Error('--state must be one of: ' + AGENT_STATES.join(', '));
5848
+ }
5849
+ update.state = String(flags.state);
5850
+ }
5851
+ if (activity != null) update.activity = String(activity);
5852
+ if (flags.ticket || flags['ticket-id']) update.ticketId = String(flags.ticket || flags['ticket-id']);
5853
+ if (flags.step) update.step = String(flags.step);
5854
+ if (flags.detail) update.detail = String(flags.detail);
5855
+
5856
+ // Reporting a ticket without a state is unambiguous: you are working it.
5857
+ if (update.ticketId && !update.state) update.state = 'working';
5858
+
5859
+ const response = await buildAxios(context.baseUrl, context.session.token, 15000)
5860
+ .post('/cli/agentHeartbeat', update).then((r) => r.data);
5861
+
5862
+ if (response && response.registered === false) {
5863
+ console.log('This agent is no longer registered (its presence expired). Re-registering.');
5864
+ await agentRegisterCommand({ ...flags, 'agent-id': agentId });
5865
+ return;
5866
+ }
5867
+
5868
+ if (flagEnabled(flags.json)) {
5869
+ console.log(JSON.stringify(response, null, 2));
5870
+ return;
5871
+ }
5872
+ const agent = (response && response.agent) || {};
5873
+ // `||` binds looser than `+`, so writing this as one expression prints only the state and silently
5874
+ // drops the ticket and activity — the two things the caller just set.
5875
+ const parts = [agent.state || 'idle'];
5876
+ if (agent.ticketId) parts.push('ticket ' + agent.ticketId);
5877
+ if (agent.activity) parts.push(agent.activity);
5878
+ console.log(parts.join(' '));
5879
+ }
5880
+
5881
+ /**
5882
+ * Ask for the work routed to this session.
5883
+ *
5884
+ * Polling, not listening: a terminal running an AI agent has no socket to push to, and the routing
5885
+ * decision it is asking about is a durable field on the ticket rather than a message that could be
5886
+ * missed. `--wait` turns a single question into a patient one.
5887
+ */
5888
+ async function agentWorkCommand(flags) {
5889
+ const agentId = resolveAgentId(flags);
5890
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
5891
+ const context = agentSessionFor(flags, local);
5892
+ const api = buildAxios(context.baseUrl, context.session.token, 20000);
5893
+ const waitSec = parsePositiveInt(flags.wait, AGENT_WORK_DEFAULT_WAIT_SEC);
5894
+ const deadline = Date.now() + (waitSec * 1000);
5895
+
5896
+ for (;;) {
5897
+ const response = await api.post('/cli/agentWork', {
5898
+ agentId,
5899
+ statuses: flags.statuses || 'all'
5900
+ }).then((r) => r.data);
5901
+
5902
+ if (response && response.registered === false) {
5903
+ console.log('This agent is no longer registered. Run: remits-cli agent register');
5904
+ process.exitCode = 1;
5905
+ return;
5906
+ }
5907
+
5908
+ const tickets = (response && response.tickets) || [];
5909
+ if (tickets.length || Date.now() >= deadline) {
5910
+ if (flagEnabled(flags.json)) {
5911
+ console.log(JSON.stringify(response, null, 2));
5912
+ return;
5913
+ }
5914
+ if (!tickets.length) {
5915
+ console.log('No tickets are routed to this agent.' +
5916
+ (waitSec ? ' (waited ' + waitSec + 's)' : ''));
5917
+ console.log('Nothing to do — the platform routes work here when an operator or a rule assigns it.');
5918
+ return;
5919
+ }
5920
+ console.log(tickets.length + ' ticket(s) routed to this agent:');
5921
+ for (const ticket of tickets) {
5922
+ console.log('');
5923
+ console.log(' ' + (ticket.id || ticket.ticketId) + ' [' + (ticket.priority || 'medium') + '/' +
5924
+ (ticket.status || 'open') + '/' + (ticket.type || 'question') + ']');
5925
+ console.log(' ' + (ticket.subject || '(no subject)'));
5926
+ console.log(' account ' + (ticket.accountName || '?') + ' (' + (ticket.accountId || '?') + ')' +
5927
+ (ticket.implementationAccountId
5928
+ ? ' implementation ' + ticket.implementationAccountName + ' (' + ticket.implementationAccountId + ')'
5929
+ : ''));
5930
+ if (ticket.description) {
5931
+ console.log(' ' + String(ticket.description).split('\n')[0].slice(0, 200));
5932
+ }
5933
+ }
5934
+ console.log('');
5935
+ console.log('Next: read it in full, then claim it.');
5936
+ console.log(' remits-cli tool --name mcp_support_ticket --input \'{"action":"read","ticketId":"' +
5937
+ (tickets[0].id || tickets[0].ticketId) + '"}\'');
5938
+ console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
5939
+ ' --activity "reproducing the issue"');
5940
+ return;
5941
+ }
5942
+
5943
+ await new Promise((resolve) => setTimeout(resolve, AGENT_WORK_POLL_MS));
5944
+ }
5945
+ }
5946
+
5947
+ async function agentListCommand(flags) {
5948
+ const cwd = process.cwd();
5949
+ const context = agentSessionFor(flags, null);
5950
+ const accountId = resolvePreferredAccountId(cwd, flags, context.session);
5951
+ const response = await buildAxios(context.baseUrl, context.session.token, 15000)
5952
+ .post('/cli/agents', {
5953
+ accountId,
5954
+ dataMode: context.dataMode,
5955
+ all: flagEnabled(flags.all)
5956
+ }).then((r) => r.data);
5957
+
5958
+ if (flagEnabled(flags.json)) {
5959
+ console.log(JSON.stringify(response, null, 2));
5960
+ return;
5961
+ }
5962
+ const agents = (response && response.agents) || [];
5963
+ if (!agents.length) {
5964
+ console.log('No agents are registered for account ' + accountId + ' in the ' + context.dataMode + ' lane.');
5965
+ return;
5966
+ }
5967
+ console.log(agents.length + ' agent(s) available for account ' + accountId + ':');
5968
+ for (const agent of agents) {
5969
+ console.log('');
5970
+ console.log(' ' + agent.agentId);
5971
+ console.log(' ' + (agent.label || agent.userName || '(unlabelled)') + ' ' + (agent.userEmail || ''));
5972
+ console.log(' state ' + (agent.state || 'idle') +
5973
+ (agent.ticketId ? ' ticket ' + agent.ticketId : '') +
5974
+ ' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
5975
+ if (agent.activity) console.log(' doing ' + agent.activity);
5976
+ console.log(' where ' + (agent.host || '?') + ':' + (agent.cwd || '?'));
5977
+ }
5978
+ }
5979
+
5980
+ async function agentReleaseCommand(flags) {
5981
+ const agentId = resolveAgentId(flags, { optional: true });
5982
+ if (!agentId) {
5983
+ console.log('No local agent is registered in this session.');
5984
+ return;
5985
+ }
5986
+ const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
5987
+ const context = agentSessionFor(flags, local);
5988
+ await buildAxios(context.baseUrl, context.session.token, 10000)
5989
+ .post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
5990
+ forgetLocalAgent(agentId);
5991
+ console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
5992
+ }
5993
+
5994
+ async function agentCommand(flags, subcommand) {
5995
+ switch (subcommand) {
5996
+ case 'register': return agentRegisterCommand(flags);
5997
+ case 'status': return agentStatusCommand(flags);
5998
+ case 'work': return agentWorkCommand(flags);
5999
+ case 'list': case 'ls': return agentListCommand(flags);
6000
+ case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
6001
+ case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
6002
+ default:
6003
+ printAgentHelp();
6004
+ return;
6005
+ }
6006
+ }
6007
+
6008
+ function printAgentHelp() {
6009
+ console.log('Usage: remits-cli agent <subcommand>');
6010
+ console.log('');
6011
+ console.log(' register [--label NAME] [--data-mode test|prod] [--account-id ID]');
6012
+ console.log(' Make THIS terminal session available to work support tickets. Starts a heartbeat');
6013
+ console.log(' that ends when this session ends, so a closed tab stops receiving work on its own.');
6014
+ console.log('');
6015
+ console.log(' work [--wait SECONDS] [--json]');
6016
+ console.log(' Ask for the tickets routed to this session. With --wait it keeps asking until');
6017
+ console.log(' something arrives or the wait runs out.');
6018
+ console.log('');
6019
+ console.log(' status [--state idle|working|paused] [--ticket ID] [--activity "..."] [--step "..."]');
6020
+ console.log(' Report what this session is doing, so operators can see it live.');
6021
+ console.log('');
6022
+ console.log(' list [--account-id ID] [--json] Who else is available for an account.');
6023
+ console.log(' release Deregister this session immediately.');
6024
+ }
6025
+
5955
6026
  async function listenCommand(flags) {
5956
6027
  if (isListenerRunning()) {
5957
6028
  const state = readServiceState();
@@ -6031,43 +6102,15 @@ async function listenCommand(flags) {
6031
6102
  return;
6032
6103
  }
6033
6104
 
6034
- // Foreground mode — this is the actual long-running listener
6035
- // Clean slate: kill any leftover tmux session and pane state from a previous run
6036
- killTmuxSession();
6037
- paneMap = {};
6038
- savePaneMap();
6039
-
6105
+ // Foreground mode — this is the actual long-running service
6040
6106
  writeListenerPid();
6041
6107
  runtimeState.service.startedAt = new Date().toISOString();
6042
6108
  runtimeState.service.pid = process.pid;
6043
6109
  recordDiscoverySummary(discoverAccountRepos());
6044
6110
  const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
6045
6111
  await startDashboardServer(dashboardPort);
6046
- await bestEffortRegisterCliListeners({ reason: 'listener.start' });
6047
- let lastListenerRegistrationAt = Date.now();
6048
6112
 
6049
- // Create the tmux session immediately so the user can attach right away
6050
- try {
6051
- const defaultDir = os.homedir();
6052
- execSync(
6053
- 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(defaultDir),
6054
- { stdio: 'ignore' }
6055
- );
6056
- configureTmuxSession();
6057
- appendGlobalActivityLog('tmux', 'session.created', {
6058
- sessionName: TMUX_SESSION_NAME,
6059
- accountDir: defaultDir,
6060
- source: 'listener.start'
6061
- });
6062
- } catch (err) {
6063
- console.error('[listener] Failed to create tmux session:', err.message);
6064
- appendGlobalActivityLog('tmux', 'session.create_failed', {
6065
- sessionName: TMUX_SESSION_NAME,
6066
- error: err.message
6067
- }, 'error');
6068
- }
6069
-
6070
- // Clean up on exit — kill tmux session and clear state
6113
+ // Clean up on exit
6071
6114
  let refreshInterval = null;
6072
6115
  const cleanup = () => {
6073
6116
  appendGlobalActivityLog('listener', 'cleanup', { pid: process.pid });
@@ -6088,9 +6131,6 @@ async function listenCommand(flags) {
6088
6131
  } catch (_) {}
6089
6132
  dashboardServer = null;
6090
6133
  }
6091
- killTmuxSession();
6092
- paneMap = {};
6093
- savePaneMap();
6094
6134
  clearListenerPid();
6095
6135
  clearServiceState();
6096
6136
  process.exit(0);
@@ -6103,7 +6143,7 @@ async function listenCommand(flags) {
6103
6143
  appendGlobalActivityLog('listener', 'foreground.started', {
6104
6144
  pid: process.pid,
6105
6145
  accountCount: entries.length,
6106
- logFile: TMUX_ACTIVITY_LOG_FILE,
6146
+ logFile: ACTIVITY_LOG_FILE,
6107
6147
  dashboardUrl: runtimeState.service.dashboardUrl
6108
6148
  });
6109
6149
 
@@ -6118,8 +6158,6 @@ async function listenCommand(flags) {
6118
6158
  }
6119
6159
  maintenanceInFlight = true;
6120
6160
  try {
6121
- ensureTmuxSessionHealthy(os.homedir());
6122
-
6123
6161
  const currentEntries = listenerSessionEntriesFromStore(readAllSessions());
6124
6162
  const currentSignature = listenerSessionSignature(currentEntries);
6125
6163
  if (currentSignature !== activeSessionSignature) {
@@ -6132,11 +6170,6 @@ async function listenCommand(flags) {
6132
6170
  activeSessionSignature = currentSignature;
6133
6171
  }
6134
6172
 
6135
- if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
6136
- await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
6137
- lastListenerRegistrationAt = Date.now();
6138
- }
6139
-
6140
6173
  await runManagedWebsocketWatchdog();
6141
6174
  updateRuntimeServiceState();
6142
6175
  } catch (err) {
@@ -6170,17 +6203,6 @@ async function listenStopCommand() {
6170
6203
  appendGlobalActivityLog('listener', 'stop.no_process');
6171
6204
  }
6172
6205
 
6173
- // Kill the tmux session and clear pane tracking
6174
- if (tmuxSessionExists()) {
6175
- killTmuxSession();
6176
- console.log('Killed tmux session:', TMUX_SESSION_NAME);
6177
- }
6178
- if (fs.existsSync(DISPATCH_STATE_FILE)) {
6179
- atomicWriteFile(DISPATCH_STATE_FILE, '{}');
6180
- appendGlobalActivityLog('tmux', 'dispatch_state.cleared', {
6181
- file: DISPATCH_STATE_FILE
6182
- });
6183
- }
6184
6206
  clearServiceState();
6185
6207
  }
6186
6208
 
@@ -6523,6 +6545,14 @@ async function main() {
6523
6545
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
6524
6546
  const wantsJson = flagEnabled(args.json);
6525
6547
 
6548
+ // The heartbeat sidecar runs for the whole life of a terminal session. It must not auto-update
6549
+ // (re-exec'ing it would drop the anchor it is watching) and it has no use for repo discovery or the
6550
+ // background service, so it short-circuits everything below.
6551
+ if (command === 'agent' && subcommand === 'heartbeat-daemon') {
6552
+ await agentHeartbeatDaemonCommand(args);
6553
+ return;
6554
+ }
6555
+
6526
6556
  if (shouldAutoUpdate(command, args)) {
6527
6557
  const requireSuccessfulAutoUpdate = command === 'start';
6528
6558
  if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
@@ -6530,13 +6560,9 @@ async function main() {
6530
6560
 
6531
6561
  // Best-effort: update account-repo index whenever running from inside a remits repo
6532
6562
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
6533
- if (!wantsJson) {
6534
- try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
6535
- }
6536
-
6537
6563
  // Auto-start the background service if not already running.
6538
6564
  // Skip for lifecycle subcommands and help.
6539
- const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami';
6565
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
6540
6566
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
6541
6567
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
6542
6568
  try {
@@ -6563,7 +6589,12 @@ async function main() {
6563
6589
  console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
6564
6590
  console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
6565
6591
  console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
6566
- console.log(' remits-cli start [--foreground true] [--port 8787]');
6592
+ console.log(' remits-cli agent register [--label NAME] [--data-mode test|prod] # make THIS terminal available for tickets');
6593
+ console.log(' remits-cli agent work [--wait SECONDS] [--json] # ask for tickets routed to this session');
6594
+ console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
6595
+ console.log(' remits-cli agent list [--account-id ID] # who else is available');
6596
+ console.log(' remits-cli agent release # stop receiving tickets now');
6597
+ console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
6567
6598
  console.log(' remits-cli stop');
6568
6599
  console.log(' remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
6569
6600
  console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
@@ -6712,6 +6743,15 @@ async function main() {
6712
6743
  return;
6713
6744
  }
6714
6745
 
6746
+ if (command === 'agent') {
6747
+ if (wantsHelp) {
6748
+ printAgentHelp();
6749
+ process.exit(0);
6750
+ }
6751
+ await agentCommand(args, subcommand);
6752
+ return;
6753
+ }
6754
+
6715
6755
  if (command === 'install') {
6716
6756
  await installSkillsCommand(args);
6717
6757
  return;
@@ -6741,6 +6781,9 @@ async function main() {
6741
6781
  }
6742
6782
 
6743
6783
  main().catch((err) => {
6784
+ if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
6785
+ console.error(err.stack);
6786
+ }
6744
6787
  console.error('remits-cli error:', describeError(err) + sandboxNetworkHint(err));
6745
6788
  process.exit(1);
6746
6789
  });