@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 +890 -847
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +151 -71
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
|
|
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(
|
|
628
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
4425
|
-
if (
|
|
4426
|
-
const errorLines =
|
|
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: '
|
|
4288
|
+
id: 'activity-errors',
|
|
4433
4289
|
severity: 'warn',
|
|
4434
4290
|
scope: 'global',
|
|
4435
|
-
title: 'Recent service
|
|
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
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
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(
|
|
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(
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
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
|
|
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:
|
|
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 ||
|
|
4735
|
-
totalCount: result.totalCount || tickets.length,
|
|
4736
|
-
count: result.count || tickets.length,
|
|
4737
|
-
counts: result.counts || {},
|
|
4738
|
-
recommendedTicket: compactSupportTicketRow(result.
|
|
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.
|
|
4741
|
-
message:
|
|
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
|
-
|
|
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="
|
|
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>
|
|
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
|
|
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:"
|
|
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
|
|
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("
|
|
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>
|
|
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>
|
|
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
|
|
4978
|
-
'function
|
|
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);
|
|
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:
|
|
4984
|
-
'document.
|
|
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
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
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
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
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
|
-
|
|
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
|
|
5157
|
-
|
|
5158
|
-
|
|
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
|
-
|
|
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
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
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
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5098
|
+
recordWebsocketMessage(baseUrl, topic, data, {
|
|
5099
|
+
source,
|
|
5100
|
+
duplicate,
|
|
5101
|
+
accountIds
|
|
5102
|
+
});
|
|
5179
5103
|
|
|
5180
|
-
|
|
5181
|
-
|
|
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
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
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
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
)
|
|
5205
|
-
|
|
5206
|
-
|
|
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
|
-
|
|
5234
|
-
|
|
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
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
});
|