@remits/remits-cli 0.1.108 → 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 +892 -897
- 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,204 +1275,6 @@ function discoverAccountRepos() {
|
|
|
1278
1275
|
};
|
|
1279
1276
|
}
|
|
1280
1277
|
|
|
1281
|
-
function listenerRegistrationRepoAccounts() {
|
|
1282
|
-
return listAccountRepoEntries(readAccountRepoIndex())
|
|
1283
|
-
.sort((a, b) => Number(a.accountId) - Number(b.accountId))
|
|
1284
|
-
.map((entry) => ({
|
|
1285
|
-
accountId: Number(entry.accountId),
|
|
1286
|
-
name: entry.name || null,
|
|
1287
|
-
type: entry.type || null,
|
|
1288
|
-
platformAccountId: entry.platformAccountId || null,
|
|
1289
|
-
routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
|
|
1290
|
-
? 'self_and_descendants'
|
|
1291
|
-
: 'self',
|
|
1292
|
-
source: entry.source || null,
|
|
1293
|
-
updatedAt: entry.updatedAt || null
|
|
1294
|
-
}));
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
function readListenerRegistrationState() {
|
|
1298
|
-
if (!fs.existsSync(LISTENER_REGISTRATION_STATE_FILE)) {
|
|
1299
|
-
return {};
|
|
1300
|
-
}
|
|
1301
|
-
try {
|
|
1302
|
-
const data = JSON.parse(fs.readFileSync(LISTENER_REGISTRATION_STATE_FILE, 'utf8'));
|
|
1303
|
-
return data && typeof data === 'object' ? data : {};
|
|
1304
|
-
} catch (_) {
|
|
1305
|
-
return {};
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
function writeListenerRegistrationState(data) {
|
|
1310
|
-
ensureSessionDir();
|
|
1311
|
-
atomicWriteFile(LISTENER_REGISTRATION_STATE_FILE, JSON.stringify(data || {}, null, 2));
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function listenerRegistrationStateKey(session) {
|
|
1315
|
-
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
1316
|
-
const userId = session.user && session.user.id ? session.user.id : 'unknown';
|
|
1317
|
-
return baseUrl + '|' + String(userId);
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
function serviceRegistrationState() {
|
|
1321
|
-
const state = readServiceState();
|
|
1322
|
-
return {
|
|
1323
|
-
running: isListenerRunning(),
|
|
1324
|
-
pid: state && state.pid ? state.pid : (fs.existsSync(LISTENER_PID_FILE) ? fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim() : null),
|
|
1325
|
-
startedAt: state && state.startedAt ? state.startedAt : runtimeState.service.startedAt,
|
|
1326
|
-
dashboardUrl: state && state.dashboardUrl ? state.dashboardUrl : runtimeState.service.dashboardUrl
|
|
1327
|
-
};
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function listenerRegistrationPayload(session, options = {}) {
|
|
1331
|
-
const service = serviceRegistrationState();
|
|
1332
|
-
const groupedSessions = Array.isArray(options.groupedSessions)
|
|
1333
|
-
? options.groupedSessions.filter((entry) => entry && entry.token)
|
|
1334
|
-
: [];
|
|
1335
|
-
const sessionDataModes = Array.from(new Set(
|
|
1336
|
-
(groupedSessions.length ? groupedSessions : [session])
|
|
1337
|
-
.map((entry) => normalizeDataMode(entry && entry.dataMode))
|
|
1338
|
-
.filter(Boolean)
|
|
1339
|
-
)).sort();
|
|
1340
|
-
const sessionAccountIds = Array.from(new Set(
|
|
1341
|
-
(groupedSessions.length ? groupedSessions : [session])
|
|
1342
|
-
.map((entry) => Number(entry && entry.accountId))
|
|
1343
|
-
.filter((accountId) => Number.isFinite(accountId) && accountId > 0)
|
|
1344
|
-
)).sort((a, b) => a - b);
|
|
1345
|
-
return {
|
|
1346
|
-
client: 'remits-cli',
|
|
1347
|
-
reason: options.reason || 'heartbeat',
|
|
1348
|
-
baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
|
|
1349
|
-
accountId: Number(session.accountId),
|
|
1350
|
-
dataMode: normalizeDataMode(session.dataMode),
|
|
1351
|
-
dataModes: sessionDataModes,
|
|
1352
|
-
sessionAccountIds,
|
|
1353
|
-
registrationSessionCount: groupedSessions.length || 1,
|
|
1354
|
-
websocketTopic: session.websocketTopic || (session.user && session.user.uuid ? String(session.user.uuid).replace(/-/g, '') : null),
|
|
1355
|
-
dashboardUrl: service.dashboardUrl || null,
|
|
1356
|
-
agent: getPreferredAgent(),
|
|
1357
|
-
service,
|
|
1358
|
-
repoAccounts: listenerRegistrationRepoAccounts()
|
|
1359
|
-
};
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
function listenerRegistrationSignature(payload) {
|
|
1363
|
-
return sha256(stableStringify({
|
|
1364
|
-
baseUrl: payload.baseUrl,
|
|
1365
|
-
accountId: payload.accountId,
|
|
1366
|
-
dataMode: payload.dataMode,
|
|
1367
|
-
dataModes: payload.dataModes || [],
|
|
1368
|
-
sessionAccountIds: payload.sessionAccountIds || [],
|
|
1369
|
-
websocketTopic: payload.websocketTopic,
|
|
1370
|
-
dashboardUrl: payload.dashboardUrl,
|
|
1371
|
-
agent: payload.agent,
|
|
1372
|
-
serviceRunning: payload.service && payload.service.running,
|
|
1373
|
-
repoAccounts: (payload.repoAccounts || []).map((entry) => ({
|
|
1374
|
-
accountId: entry.accountId,
|
|
1375
|
-
type: entry.type,
|
|
1376
|
-
routingScope: entry.routingScope,
|
|
1377
|
-
updatedAt: entry.updatedAt
|
|
1378
|
-
}))
|
|
1379
|
-
}));
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
function shouldRegisterCliListener(session, payload, options = {}) {
|
|
1383
|
-
if (options.force === true) {
|
|
1384
|
-
return true;
|
|
1385
|
-
}
|
|
1386
|
-
const state = readListenerRegistrationState();
|
|
1387
|
-
const key = listenerRegistrationStateKey(session);
|
|
1388
|
-
const existing = state[key];
|
|
1389
|
-
if (!existing) {
|
|
1390
|
-
return true;
|
|
1391
|
-
}
|
|
1392
|
-
const lastRegisteredAtMs = Number(existing.lastRegisteredAtMs || 0);
|
|
1393
|
-
if (!lastRegisteredAtMs || Date.now() - lastRegisteredAtMs >= LISTENER_REGISTRATION_MIN_INTERVAL_MS) {
|
|
1394
|
-
return true;
|
|
1395
|
-
}
|
|
1396
|
-
return existing.signature !== listenerRegistrationSignature(payload);
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
function rememberCliListenerRegistration(session, payload) {
|
|
1400
|
-
const state = readListenerRegistrationState();
|
|
1401
|
-
const key = listenerRegistrationStateKey(session);
|
|
1402
|
-
state[key] = {
|
|
1403
|
-
baseUrl: payload.baseUrl,
|
|
1404
|
-
userId: session.user && session.user.id ? session.user.id : null,
|
|
1405
|
-
signature: listenerRegistrationSignature(payload),
|
|
1406
|
-
accountCount: Array.isArray(payload.repoAccounts) ? payload.repoAccounts.length : 0,
|
|
1407
|
-
sessionAccountIds: payload.sessionAccountIds || [],
|
|
1408
|
-
dataModes: payload.dataModes || [],
|
|
1409
|
-
registrationSessionCount: payload.registrationSessionCount || 1,
|
|
1410
|
-
lastRegisteredAt: new Date().toISOString(),
|
|
1411
|
-
lastRegisteredAtMs: Date.now()
|
|
1412
|
-
};
|
|
1413
|
-
writeListenerRegistrationState(state);
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
async function registerCliListenerSession(session, options = {}) {
|
|
1417
|
-
if (!session || !session.token) {
|
|
1418
|
-
return null;
|
|
1419
|
-
}
|
|
1420
|
-
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
1421
|
-
const payload = listenerRegistrationPayload(session, options);
|
|
1422
|
-
if (!shouldRegisterCliListener(session, payload, options)) {
|
|
1423
|
-
return { success: true, skipped: true, reason: 'recently_registered' };
|
|
1424
|
-
}
|
|
1425
|
-
const api = buildAxios(baseUrl, session.token, parsePositiveInt(options.timeoutMs, 5000));
|
|
1426
|
-
const response = await api.post('/cli/register', payload);
|
|
1427
|
-
rememberCliListenerRegistration(session, payload);
|
|
1428
|
-
return response.data;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
async function bestEffortRegisterCliListeners(options = {}) {
|
|
1432
|
-
const sessions = listSessionsFromStore(readAllSessions()).filter((session) => session && session.token);
|
|
1433
|
-
if (!sessions.length) {
|
|
1434
|
-
return [];
|
|
1435
|
-
}
|
|
1436
|
-
const groupedSessions = [];
|
|
1437
|
-
for (const session of sessions) {
|
|
1438
|
-
const key = listenerRegistrationStateKey(session);
|
|
1439
|
-
let group = groupedSessions.find((entry) => entry.key === key);
|
|
1440
|
-
if (!group) {
|
|
1441
|
-
group = { key, session, sessions: [] };
|
|
1442
|
-
groupedSessions.push(group);
|
|
1443
|
-
}
|
|
1444
|
-
group.sessions.push(session);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
const results = await Promise.all(groupedSessions.map(async (group) => {
|
|
1448
|
-
try {
|
|
1449
|
-
const data = await registerCliListenerSession(group.session, {
|
|
1450
|
-
...options,
|
|
1451
|
-
groupedSessions: group.sessions
|
|
1452
|
-
});
|
|
1453
|
-
return {
|
|
1454
|
-
success: true,
|
|
1455
|
-
accountId: group.session.accountId,
|
|
1456
|
-
baseUrl: group.session.baseUrl,
|
|
1457
|
-
groupedAccountIds: group.sessions.map((session) => session.accountId),
|
|
1458
|
-
data
|
|
1459
|
-
};
|
|
1460
|
-
} catch (err) {
|
|
1461
|
-
appendGlobalActivityLog('listener', 'registration.failed', {
|
|
1462
|
-
accountId: group.session.accountId,
|
|
1463
|
-
baseUrl: normalizeBaseUrl(group.session.baseUrl || DEFAULT_BASE_URL),
|
|
1464
|
-
groupedAccountIds: group.sessions.map((session) => session.accountId),
|
|
1465
|
-
error: describeError(err)
|
|
1466
|
-
}, 'error');
|
|
1467
|
-
return {
|
|
1468
|
-
success: false,
|
|
1469
|
-
accountId: group.session.accountId,
|
|
1470
|
-
baseUrl: group.session.baseUrl,
|
|
1471
|
-
groupedAccountIds: group.sessions.map((session) => session.accountId),
|
|
1472
|
-
error: describeError(err)
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
}));
|
|
1476
|
-
return results;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
1278
|
function resolvePreferredAccountId(cwd, flags, session) {
|
|
1480
1279
|
// Explicit --account-id wins over the repo's account-info.json so users can
|
|
1481
1280
|
// target a child account (e.g. a customer) from a parent-account repo.
|
|
@@ -1839,7 +1638,16 @@ function describeError(err) {
|
|
|
1839
1638
|
if (responseData.message) {
|
|
1840
1639
|
parts.push(String(responseData.message));
|
|
1841
1640
|
} else if (typeof responseData === 'string') {
|
|
1842
|
-
|
|
1641
|
+
// A server that answers an unknown route with an HTML error page would otherwise put a whole
|
|
1642
|
+
// document into a log line or a dashboard panel. The status is the useful part; the page is not.
|
|
1643
|
+
const looksLikeHtml = /^\s*(<!doctype|<html)/i.test(responseData);
|
|
1644
|
+
if (looksLikeHtml) {
|
|
1645
|
+
const status = err.response.status ? 'HTTP ' + err.response.status : 'HTTP error';
|
|
1646
|
+
const title = (responseData.match(/<title[^>]*>([^<]{0,120})<\/title>/i) || [])[1];
|
|
1647
|
+
parts.push(status + (title ? ' - ' + title.trim() : ' (HTML error page)'));
|
|
1648
|
+
} else {
|
|
1649
|
+
parts.push(responseData.length > 500 ? responseData.slice(0, 500) + '...' : responseData);
|
|
1650
|
+
}
|
|
1843
1651
|
}
|
|
1844
1652
|
}
|
|
1845
1653
|
if (err.message) {
|
|
@@ -1890,7 +1698,7 @@ function isLikelySandboxNetworkError(err) {
|
|
|
1890
1698
|
function sandboxNetworkHint(err) {
|
|
1891
1699
|
if (!isLikelySandboxNetworkError(err)) return '';
|
|
1892
1700
|
const baseUrl = err && err.config && err.config.baseURL ? err.config.baseURL : DEFAULT_BASE_URL;
|
|
1893
|
-
return ' Network access to ' + baseUrl + ' appears unavailable from the current sandbox. If remits-cli is being run by a local AI agent (for example Codex
|
|
1701
|
+
return ' Network access to ' + baseUrl + ' appears unavailable from the current sandbox. If remits-cli is being run by a local AI agent (for example Codex or Claude Code), retry the command with escalated permissions or outside the sandbox.';
|
|
1894
1702
|
}
|
|
1895
1703
|
|
|
1896
1704
|
function saveToolsSnapshot(cwd, data) {
|
|
@@ -2190,10 +1998,6 @@ async function authCommand(flags) {
|
|
|
2190
1998
|
});
|
|
2191
1999
|
console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
|
|
2192
2000
|
console.log('Data mode:', session.dataMode);
|
|
2193
|
-
try {
|
|
2194
|
-
await bestEffortRegisterCliListeners({ cwd, reason: 'auth' });
|
|
2195
|
-
} catch (_) { /* best-effort */ }
|
|
2196
|
-
|
|
2197
2001
|
try {
|
|
2198
2002
|
const branchName = currentBranch(cwd);
|
|
2199
2003
|
const toolsData = await loggedPost(api, cwd, '/cli/tools', {
|
|
@@ -4237,6 +4041,10 @@ let supportTicketSnapshotCache = {
|
|
|
4237
4041
|
fetchedAtMs: 0,
|
|
4238
4042
|
value: null
|
|
4239
4043
|
};
|
|
4044
|
+
let agentSnapshotCache = {
|
|
4045
|
+
fetchedAtMs: 0,
|
|
4046
|
+
value: null
|
|
4047
|
+
};
|
|
4240
4048
|
|
|
4241
4049
|
function listenerSessionEntriesFromStore(store) {
|
|
4242
4050
|
return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
|
|
@@ -4469,18 +4277,18 @@ function slugify(value) {
|
|
|
4469
4277
|
|
|
4470
4278
|
function detectIssueSignals(repoEntries, globalFiles) {
|
|
4471
4279
|
const issues = [];
|
|
4472
|
-
const
|
|
4473
|
-
if (
|
|
4474
|
-
const errorLines =
|
|
4280
|
+
const activityLog = (globalFiles || []).find((file) => file.label === 'activity.log (tail)');
|
|
4281
|
+
if (activityLog && activityLog.content) {
|
|
4282
|
+
const errorLines = activityLog.content
|
|
4475
4283
|
.split('\n')
|
|
4476
4284
|
.filter((line) => /\[error\]/i.test(line))
|
|
4477
4285
|
.slice(-8);
|
|
4478
4286
|
if (errorLines.length) {
|
|
4479
4287
|
issues.push({
|
|
4480
|
-
id: '
|
|
4288
|
+
id: 'activity-errors',
|
|
4481
4289
|
severity: 'warn',
|
|
4482
4290
|
scope: 'global',
|
|
4483
|
-
title: 'Recent service
|
|
4291
|
+
title: 'Recent service errors detected',
|
|
4484
4292
|
summary: 'The global remits-cli activity log contains recent error-level events.',
|
|
4485
4293
|
evidence: errorLines,
|
|
4486
4294
|
recommendation: 'Review the activity tail and export an escalation bundle if this reflects unexpected platform or dispatch behavior.'
|
|
@@ -4604,11 +4412,10 @@ function exportIssueBundle(snapshot) {
|
|
|
4604
4412
|
service: snapshot.service,
|
|
4605
4413
|
discovery: snapshot.discovery,
|
|
4606
4414
|
issueCount: (snapshot.issues || []).length,
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
livePaneCount: snapshot.tmux && snapshot.tmux.livePanes ? snapshot.tmux.livePanes.length : 0
|
|
4415
|
+
agents: {
|
|
4416
|
+
localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
|
|
4417
|
+
registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
|
|
4418
|
+
errors: (snapshot.agents && snapshot.agents.errors) || []
|
|
4612
4419
|
},
|
|
4613
4420
|
repos: (snapshot.repos || []).map((repo) => ({
|
|
4614
4421
|
accountId: repo.accountId,
|
|
@@ -4624,10 +4431,9 @@ function exportIssueBundle(snapshot) {
|
|
|
4624
4431
|
copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
|
|
4625
4432
|
copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
|
|
4626
4433
|
copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
|
|
4627
|
-
copyFileIfExists(
|
|
4434
|
+
copyFileIfExists(AGENT_STATE_FILE, path.join(globalDir, 'agents.json'));
|
|
4628
4435
|
copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
|
|
4629
|
-
copyFileIfExists(
|
|
4630
|
-
copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
|
|
4436
|
+
copyFileIfExists(ACTIVITY_LOG_FILE, path.join(globalDir, 'activity.log'));
|
|
4631
4437
|
|
|
4632
4438
|
const reposDir = path.join(bundleDir, 'repos');
|
|
4633
4439
|
ensureDir(reposDir);
|
|
@@ -4666,10 +4472,9 @@ function buildGlobalFileSnapshot() {
|
|
|
4666
4472
|
{ label: 'config.json', path: CONFIG_FILE, mode: 'json' },
|
|
4667
4473
|
{ label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
|
|
4668
4474
|
{ label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
|
|
4669
|
-
{ label: '
|
|
4670
|
-
{ label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
|
|
4475
|
+
{ label: 'agents.json', path: AGENT_STATE_FILE, mode: 'json' },
|
|
4671
4476
|
{ label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
|
|
4672
|
-
{ label: '
|
|
4477
|
+
{ label: 'activity.log (tail)', path: ACTIVITY_LOG_FILE, mode: 'tail' }
|
|
4673
4478
|
];
|
|
4674
4479
|
|
|
4675
4480
|
return files.map((file) => {
|
|
@@ -4737,6 +4542,9 @@ function compactSupportTicketRow(row) {
|
|
|
4737
4542
|
type: row.type || null,
|
|
4738
4543
|
source: row.source || null,
|
|
4739
4544
|
assignedTo: row.assignedTo || null,
|
|
4545
|
+
routedAgentId: row.routedAgentId || null,
|
|
4546
|
+
routedAgentLabel: row.routedAgentLabel || null,
|
|
4547
|
+
routedAt: row.routedAt || null,
|
|
4740
4548
|
implementationAccountId: row.implementationAccountId || null,
|
|
4741
4549
|
implementationAccountName: row.implementationAccountName || null,
|
|
4742
4550
|
affectedComponent: row.affectedComponent || null,
|
|
@@ -4746,48 +4554,126 @@ function compactSupportTicketRow(row) {
|
|
|
4746
4554
|
};
|
|
4747
4555
|
}
|
|
4748
4556
|
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4557
|
+
/**
|
|
4558
|
+
* The support-ticket queue for one authenticated session.
|
|
4559
|
+
*
|
|
4560
|
+
* Reads the PLATFORM's generic queue endpoint, deliberately not a front-stage tool. remits-cli has
|
|
4561
|
+
* to show the same ticket surface on every Remits platform, whatever helpdesk that platform
|
|
4562
|
+
* integrates with and whatever workflow it layers on top; product-specific views belong in that
|
|
4563
|
+
* product's own Embeddables.
|
|
4564
|
+
*/
|
|
4565
|
+
async function fetchSupportTicketQueueForSession(session) {
|
|
4566
|
+
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4567
|
+
const api = buildAxios(baseUrl, session.token, 15000);
|
|
4568
|
+
const result = await api.post('/cli/tickets', {
|
|
4752
4569
|
accountId: Number(session.accountId),
|
|
4753
4570
|
includeChildren: true,
|
|
4754
|
-
includeRoot: true,
|
|
4755
4571
|
sortBy: 'triage',
|
|
4756
4572
|
limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
|
|
4757
|
-
};
|
|
4758
|
-
}
|
|
4759
|
-
|
|
4760
|
-
async function fetchSupportTicketQueueForSession(session) {
|
|
4761
|
-
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4762
|
-
const api = buildAxios(baseUrl, session.token, 12000);
|
|
4763
|
-
const response = await api.post('/cli/tool', {
|
|
4764
|
-
token: session.token,
|
|
4765
|
-
accountId: Number(session.accountId),
|
|
4766
|
-
branchName: '',
|
|
4767
|
-
dataMode: 'prod',
|
|
4768
|
-
name: 'mcp_support_ticket_queue',
|
|
4769
|
-
callId: 'dashboard-support-ticket-queue-' + crypto.randomUUID(),
|
|
4770
|
-
input: supportTicketQueueInput(session)
|
|
4771
4573
|
}).then((r) => r.data);
|
|
4772
4574
|
|
|
4773
|
-
const
|
|
4774
|
-
const tickets = Array.isArray(result.tickets)
|
|
4575
|
+
const tickets = Array.isArray(result && result.tickets)
|
|
4775
4576
|
? result.tickets.map(compactSupportTicketRow).filter(Boolean)
|
|
4776
4577
|
: [];
|
|
4777
4578
|
return {
|
|
4778
|
-
success:
|
|
4579
|
+
success: !!(result && result.success !== false),
|
|
4779
4580
|
baseUrl,
|
|
4780
4581
|
accountId: Number(session.accountId),
|
|
4781
4582
|
userId: session.user && session.user.id ? session.user.id : null,
|
|
4782
|
-
dataMode: result.dataMode ||
|
|
4783
|
-
totalCount: result.totalCount || tickets.length,
|
|
4784
|
-
count: result.count || tickets.length,
|
|
4785
|
-
counts: result.counts || {},
|
|
4786
|
-
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),
|
|
4787
4588
|
tickets,
|
|
4788
|
-
searchedAccounts: result.
|
|
4789
|
-
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
|
|
4790
4674
|
};
|
|
4675
|
+
agentSnapshotCache = { fetchedAtMs: nowMs, value };
|
|
4676
|
+
return value;
|
|
4791
4677
|
}
|
|
4792
4678
|
|
|
4793
4679
|
async function collectSupportTicketSnapshot() {
|
|
@@ -4845,6 +4731,13 @@ async function collectSupportTicketSnapshot() {
|
|
|
4845
4731
|
refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
|
|
4846
4732
|
queueCount: queues.length,
|
|
4847
4733
|
totalCount: queues.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0),
|
|
4734
|
+
counts: queues.reduce((totals, queue) => {
|
|
4735
|
+
const counts = queue.counts || {};
|
|
4736
|
+
totals.routed = Number(totals.routed || 0) + Number(counts.routed || 0);
|
|
4737
|
+
totals.unrouted = Number(totals.unrouted || 0) + Number(counts.unrouted || 0);
|
|
4738
|
+
totals.assigned = Number(totals.assigned || 0) + Number(counts.assigned || 0);
|
|
4739
|
+
return totals;
|
|
4740
|
+
}, {}),
|
|
4848
4741
|
tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
|
|
4849
4742
|
queues,
|
|
4850
4743
|
errors
|
|
@@ -4865,29 +4758,6 @@ async function collectDashboardSnapshot() {
|
|
|
4865
4758
|
const globalFiles = buildGlobalFileSnapshot();
|
|
4866
4759
|
const issues = detectIssueSignals(repoEntries, globalFiles);
|
|
4867
4760
|
|
|
4868
|
-
const paneState = fs.existsSync(DISPATCH_STATE_FILE) ? readJsonFilePretty(DISPATCH_STATE_FILE) : null;
|
|
4869
|
-
const livePanes = [];
|
|
4870
|
-
try {
|
|
4871
|
-
if (isTmuxAvailable() && tmuxSessionExists()) {
|
|
4872
|
-
const output = execSync(
|
|
4873
|
-
'tmux list-panes -t ' + shellQuote(TMUX_SESSION_NAME) + ' -F ' + shellQuote('#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_current_path}\t#{pane_dead}'),
|
|
4874
|
-
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
4875
|
-
).toString();
|
|
4876
|
-
for (const line of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
|
|
4877
|
-
const parts = line.split('\t');
|
|
4878
|
-
livePanes.push({
|
|
4879
|
-
session: parts[0] || null,
|
|
4880
|
-
window: parts[1] || null,
|
|
4881
|
-
paneId: parts[2] || null,
|
|
4882
|
-
currentPath: parts[3] || null,
|
|
4883
|
-
dead: parts[4] === '1'
|
|
4884
|
-
});
|
|
4885
|
-
}
|
|
4886
|
-
}
|
|
4887
|
-
} catch (_) {
|
|
4888
|
-
// Dashboard should remain readable even if tmux inspection fails.
|
|
4889
|
-
}
|
|
4890
|
-
|
|
4891
4761
|
return {
|
|
4892
4762
|
generatedAt: new Date().toISOString(),
|
|
4893
4763
|
service: {
|
|
@@ -4899,14 +4769,7 @@ async function collectDashboardSnapshot() {
|
|
|
4899
4769
|
websocket: {
|
|
4900
4770
|
connections: runtimeState.websocket
|
|
4901
4771
|
},
|
|
4902
|
-
|
|
4903
|
-
available: isTmuxAvailable(),
|
|
4904
|
-
terminalLaunchSupported: canLaunchTerminalForTmux(),
|
|
4905
|
-
sessionName: TMUX_SESSION_NAME,
|
|
4906
|
-
sessionExists: tmuxSessionExists(),
|
|
4907
|
-
trackedPanes: paneState,
|
|
4908
|
-
livePanes
|
|
4909
|
-
},
|
|
4772
|
+
agents: await collectAgentSnapshot(),
|
|
4910
4773
|
discovery: runtimeState.discovery,
|
|
4911
4774
|
globalFiles,
|
|
4912
4775
|
repos: repoEntries,
|
|
@@ -4952,9 +4815,7 @@ function renderDashboardHtml(snapshot) {
|
|
|
4952
4815
|
'<button class="action-btn" data-action="export-issues">Export Issue Bundle</button>' +
|
|
4953
4816
|
'<button class="action-btn" data-action="rescan-repos">Rescan Account Repos</button>' +
|
|
4954
4817
|
'<button class="action-btn" data-action="reconnect-websockets">Reconnect Websockets</button>' +
|
|
4955
|
-
'<button class="action-btn" data-action="
|
|
4956
|
-
'<button class="action-btn" data-action="start-tmux">Ensure Tmux Session</button>' +
|
|
4957
|
-
'<button class="action-btn" data-action="stop-tmux">Kill Tmux Session</button>' +
|
|
4818
|
+
'<button class="action-btn" data-action="refresh-agents">Refresh Agents</button>' +
|
|
4958
4819
|
'</div>' +
|
|
4959
4820
|
'</section>' +
|
|
4960
4821
|
'<div class="section-grid">' +
|
|
@@ -4967,6 +4828,9 @@ function renderDashboardHtml(snapshot) {
|
|
|
4967
4828
|
'<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
|
|
4968
4829
|
'</section>' +
|
|
4969
4830
|
'</div>' +
|
|
4831
|
+
'<div class="section-grid">' +
|
|
4832
|
+
'<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
|
|
4833
|
+
'</div>' +
|
|
4970
4834
|
'<div class="section-grid">' +
|
|
4971
4835
|
'<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
|
|
4972
4836
|
'</div>' +
|
|
@@ -4975,7 +4839,7 @@ function renderDashboardHtml(snapshot) {
|
|
|
4975
4839
|
'</div>' +
|
|
4976
4840
|
'<div class="section-grid">' +
|
|
4977
4841
|
'<section class="panel"><h2>Websocket Connections</h2><div id="websocket-section" class="accordion"></div></section>' +
|
|
4978
|
-
'<section class="panel"><h2>
|
|
4842
|
+
'<section class="panel"><h2>Activity Log</h2><div id="activity-section" class="accordion"></div></section>' +
|
|
4979
4843
|
'</div>' +
|
|
4980
4844
|
'<div class="section-grid">' +
|
|
4981
4845
|
'<section class="panel"><h2>Global Files</h2><div id="global-files" class="accordion"></div></section>' +
|
|
@@ -5000,36 +4864,39 @@ function renderDashboardHtml(snapshot) {
|
|
|
5000
4864
|
'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
|
|
5001
4865
|
'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
|
|
5002
4866
|
'function detailsBlock(key,title,meta,bodyHtml,openByDefault){return \'<details class="item" data-key="\'+escapeHtml(key)+\'"\'+(openByDefault?" open":"")+\'><summary><div class="summary-main"><div class="summary-text"><span class="summary-title">\'+escapeHtml(title)+\'</span><span class="summary-meta">\'+meta+\'</span></div></div><span class="summary-caret">›</span></summary><div class="item-body">\'+bodyHtml+\'</div></details>\';}' +
|
|
5003
|
-
'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const
|
|
4867
|
+
'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const agentState=state.agents||{};const agentList=agentState.registered||[];const idleAgents=agentList.filter(function(a){return (a.state||"idle")==="idle";}).length;const cards=[' +
|
|
5004
4868
|
'{title:"Service",value:state.service&&state.service.running?"Running":"Stopped",label:"Daemon status",tone:statusTone(state.service&&state.service.running),sub:(state.service&&state.service.dashboardUrl?state.service.dashboardUrl:"No dashboard URL yet")},' +
|
|
5005
4869
|
'{title:"Websockets",value:String(connectedCount)+"/"+String(wsValues.length||0),label:"Connected topics groups",tone:errorCount? "bad" : (warnCount? "warn" : "ok"),sub: errorCount? String(errorCount)+" unhealthy connection(s)" : (warnCount? String(warnCount)+" connecting" : "All connections healthy")},' +
|
|
5006
4870
|
'{title:"Tickets",value:String(ticketTotal),label:"Open support queue",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":"Fetched "+String(ticketState.queueCount||0)+" queue(s)"},' +
|
|
5007
|
-
'{title:"
|
|
4871
|
+
'{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent register"},' +
|
|
5008
4872
|
'{title:"Issues",value:String(issueCount),label:"Detected escalation signals",tone:criticalIssueCount?"bad":(issueCount?"warn":"ok"),sub:issueCount?(criticalIssueCount?String(criticalIssueCount)+" critical signal(s)":"Review before continuing"):"No issue signals detected"},' +
|
|
5009
4873
|
'{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
|
|
5010
4874
|
'];document.getElementById("status-grid").innerHTML=cards.map(function(card){return \'<section class="status-card \'+card.tone+\'"><div class="status-head"><span class="status-dot"></span><div><div class="status-label">\'+escapeHtml(card.title)+\'</div><div class="status-value">\'+escapeHtml(card.value)+\'</div></div></div><div class="status-sub"><strong>\'+escapeHtml(card.label)+\':</strong> \'+escapeHtml(card.sub)+\'</div></section>\';}).join("");}' +
|
|
5011
|
-
'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const
|
|
4875
|
+
'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const ticketCounts=(state.supportTickets||{}).counts||{};document.getElementById("summary-grid").innerHTML=[' +
|
|
5012
4876
|
'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
|
|
5013
4877
|
'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
|
|
5014
4878
|
'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
|
|
5015
4879
|
'summaryItem("Connections",String(ws.length)),' +
|
|
5016
4880
|
'summaryItem("Topics",String(topics)),' +
|
|
5017
4881
|
'summaryItem("Open Tickets",String(state.supportTickets&&state.supportTickets.totalCount||0)),' +
|
|
5018
|
-
'summaryItem("
|
|
4882
|
+
'summaryItem("Available Agents",String(agentList.length)),' +
|
|
4883
|
+
'summaryItem("Routed Tickets",String(ticketCounts.routed||0))' +
|
|
5019
4884
|
'].join("");}' +
|
|
5020
4885
|
'function renderLastAction(state){const target=document.getElementById("last-action");if(!state.lastAction){target.className="empty";target.textContent="No dashboard actions have been triggered yet.";return;}target.className="summary-item";target.innerHTML=\'<span class="k">Last Action</span><span class="v">\'+escapeHtml(state.lastAction.action||"unknown")+\'</span><div class="muted" style="margin-top:8px;">\'+escapeHtml(formatTime(state.lastAction.at))+\'</div>\'+(state.lastAction.bundlePath?\'<div class="muted" style="margin-top:8px;word-break:break-word;"><strong>Bundle:</strong> \'+escapeHtml(state.lastAction.bundlePath)+\'</div>\':"");}' +
|
|
5021
4886
|
'function renderIssues(state){const target=document.getElementById("issues-section");const issues=state.issues||[];if(!issues.length){target.innerHTML=\'<div class="empty">No automatic issue signals are currently detected. If something still feels wrong, you can still click "Export Issue Bundle" to prepare a package for a Remits system admin.</div>\';return;}target.innerHTML=issues.map(function(issue,index){const tone=issue.severity==="bad"?"bad":"warn";const meta=badge(issue.severity==="bad"?"critical":"warning",tone)+\' <span class="muted">\'+escapeHtml(issue.scope+(issue.repoAccountId?(" account "+issue.repoAccountId):""))+\'</span>\';const evidence=(issue.evidence||[]).map(function(line){return "<li>"+escapeHtml(line)+"</li>";}).join("");const body=\'<div class="stack"><div class="muted">\'+escapeHtml(issue.summary||"")+\'</div><div class="mini-card"><strong>Recommendation</strong>\'+escapeHtml(issue.recommendation||"") + \'</div><div><strong style="display:block;margin-bottom:8px;">Evidence</strong><ul style="margin:0;padding-left:18px;display:grid;gap:8px;">\'+(evidence||"<li>No evidence lines captured.</li>")+\'</ul></div></div>\';return detailsBlock("issue:"+issue.id,(index+1)+". "+issue.title,meta,body,index===0);}).join("");}' +
|
|
5022
|
-
'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>
|
|
5023
|
-
'function supportTicketDetails(ticket,key){const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>
|
|
4887
|
+
'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];const counts=support.counts||{};let html=\'<div class="mini-grid"><div class="mini-card"><strong>Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Routed To An Agent</strong>\'+escapeHtml(String(counts.routed||0))+\'</div><div class="mini-card"><strong>Waiting</strong>\'+escapeHtml(String(counts.unrouted==null?Math.max(0,(support.totalCount||0)-(counts.routed||0)):counts.unrouted))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}if(tickets.length){html+=tickets.map(function(ticket,index){const routed=!!ticket.routedAgentId;const meta=badge(ticket.priority||"priority",ticket.priority==="critical"||ticket.priority==="high"?"bad":"warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' \'+badge(routed?("routed: "+(ticket.routedAgentLabel||ticket.routedAgentId)):"unrouted",routed?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(state,ticket,"support-ticket:"+index),index===0);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned.</div>\';}target.innerHTML=html;}' +
|
|
4888
|
+
'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
|
|
5024
4889
|
'function renderWebsockets(state){const entries=Object.entries((state.websocket&&state.websocket.connections)||{});const target=document.getElementById("websocket-section");if(!entries.length){target.innerHTML=\'<div class="empty">No websocket connections are registered yet.</div>\';return;}target.innerHTML=entries.map(function(pair){const baseUrl=pair[0];const info=pair[1]||{};const topicEntries=Object.entries(info.topics||{});const recentMessages=Array.isArray(info.recentMessages)?info.recentMessages:[];const tone=info.status==="connected"?"ok":(info.status==="connecting"?"warn":"bad");const summaryMeta=badge(info.status||"unknown",tone)+\' <span class="muted">\' + escapeHtml(String(topicEntries.length)) + \' topic(s)</span>\' + \' <span class="muted">\' + escapeHtml(String(recentMessages.length)) + \' recent message(s)</span>\';const topicBody=topicEntries.length?topicEntries.map(function(topicPair){const topic=topicPair[0];const topicInfo=topicPair[1]||{};const mini=\'<div class="mini-grid"><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((topicInfo.accountIds||[]).join(", ")||"n/a")+\'</div><div class="mini-card"><strong>Subscribed</strong>\'+escapeHtml(formatTime(topicInfo.subscribedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(topicInfo.lastMessageAt))+\'</div></div>\';return detailsBlock("ws-topic:"+baseUrl+":"+topic,topic,"topic subscription",mini,false);}).join(""):\'<div class="empty">No topic subscriptions recorded.</div>\';const messageBody=recentMessages.length?recentMessages.map(function(message,index){const messageMeta=badge(message.duplicate?"duplicate":"handled",message.duplicate?"warn":"ok")+\' <span class="muted">\'+escapeHtml(message.source||"live")+\'</span>\' + \' <span class="muted">\'+escapeHtml(message.type||"unknown-type")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Message ID</strong>\'+escapeHtml(message.messageId||"n/a")+\'</div><div class="mini-card"><strong>Timestamp</strong>\'+escapeHtml(message.timestamp?String(message.timestamp):"n/a")+\'</div><div class="mini-card"><strong>Received</strong>\'+escapeHtml(formatTime(message.receivedAt))+\'</div><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((message.accountIds||[]).join(", ")||"n/a")+\'</div></div>\'+codeBlock("ws-msg:"+baseUrl+":"+(message.messageId||index),JSON.stringify(message.payload||{},null,2)) + \'</div>\';return detailsBlock("ws-message:"+baseUrl+":"+(message.messageId||index),(message.type||"message")+" "+(message.messageId||""),messageMeta,body,false);}).join(""):\'<div class="empty">No recent websocket messages have been recorded yet.</div>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Status</strong>\'+escapeHtml(info.status||"unknown")+\'</div><div class="mini-card"><strong>Connected</strong>\'+escapeHtml(formatTime(info.connectedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(info.lastMessageAt))+\'</div><div class="mini-card"><strong>Last Error</strong>\'+escapeHtml(info.lastError||"none")+\'</div></div><div class="stack">\'+detailsBlock("ws-recent:"+baseUrl,"Recent messages","latest live and missed-message traffic",messageBody,false)+topicBody+\'</div></div>\';return detailsBlock("ws:"+baseUrl,baseUrl,summaryMeta,body,entries.length===1);}).join("");}' +
|
|
5025
|
-
'function
|
|
5026
|
-
'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("");}' +
|
|
5027
4894
|
'function renderRepos(state){const target=document.getElementById("repo-section");const repos=state.repos||[];if(!repos.length){target.innerHTML=\'<div class="empty">No indexed account repos were found. Use "Rescan Account Repos" to rebuild the index.</div>\';return;}target.innerHTML=repos.map(function(repo){const tone=repo.exists?"ok":"warn";const meta=badge(repo.exists?"available":"missing",tone)+\' <span class="muted">type: \'+escapeHtml(repo.type||"n/a")+\'</span>\';const files=(repo.files||[]).map(function(file){const fileMeta=badge(file.exists?"present":"missing",file.exists?"ok":"warn")+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("repo:"+repo.accountId+":"+file.label,file.content,file.label.indexOf("session log")>=0?"log":"")+\'</div>\';return detailsBlock("repo-file:"+repo.accountId+":"+file.label,file.label,fileMeta,body,false);}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Directory</strong>\'+escapeHtml(repo.directory||"")+\'</div><div class="mini-card"><strong>Source</strong>\'+escapeHtml(repo.source||"n/a")+\'</div><div class="mini-card"><strong>Platform Account</strong>\'+escapeHtml(String(repo.platformAccountId||"n/a"))+\'</div></div><div class="repo-files">\'+files+\'</div></div>\';return detailsBlock("repo:"+repo.accountId,(repo.name||"Unknown")+" ("+repo.accountId+")",meta,body,false);}).join("");}' +
|
|
5028
|
-
'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);
|
|
4895
|
+
'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderAgents(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
|
|
5029
4896
|
'async function fetchState(){const res=await fetch("/api/state",{cache:"no-store"});if(!res.ok)throw new Error("State refresh failed with "+res.status);return res.json();}' +
|
|
5030
4897
|
'function showToast(message){const toast=document.getElementById("toast");toast.textContent=message;toast.classList.add("show");clearTimeout(showToast._timer);showToast._timer=setTimeout(function(){toast.classList.remove("show");},2400);}' +
|
|
5031
|
-
'async function triggerAction(action,button){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:
|
|
5032
|
-
'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);});' +
|
|
5033
4900
|
'renderAll(dashboardState);' +
|
|
5034
4901
|
'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
|
|
5035
4902
|
'})();' +
|
|
@@ -5116,15 +4983,19 @@ function startDashboardServer(preferredPort) {
|
|
|
5116
4983
|
} else if (action === 'rescan-repos') {
|
|
5117
4984
|
recordDiscoverySummary(discoverAccountRepos());
|
|
5118
4985
|
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
} else if (action === 'start-tmux') {
|
|
5123
|
-
ensureTmuxSession(os.homedir());
|
|
5124
|
-
} else if (action === 'stop-tmux') {
|
|
5125
|
-
killTmuxSession();
|
|
4986
|
+
} else if (action === 'refresh-agents') {
|
|
4987
|
+
agentSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
4988
|
+
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5126
4989
|
} else if (action === 'reconnect-websockets') {
|
|
5127
4990
|
await reconnectManagedWebsockets();
|
|
4991
|
+
} else if (action === 'route-ticket') {
|
|
4992
|
+
actionResult = await routeTicketFromDashboard({
|
|
4993
|
+
ticketId: params.get('ticketId'),
|
|
4994
|
+
accountId: params.get('accountId'),
|
|
4995
|
+
agentId: params.get('agentId')
|
|
4996
|
+
});
|
|
4997
|
+
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
4998
|
+
agentSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5128
4999
|
}
|
|
5129
5000
|
if (wantsJson) {
|
|
5130
5001
|
res.statusCode = 200;
|
|
@@ -5186,518 +5057,110 @@ function startDashboardServer(preferredPort) {
|
|
|
5186
5057
|
});
|
|
5187
5058
|
}
|
|
5188
5059
|
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
// In-memory map of support-ticket routing key -> tmux pane ID (persisted for crash recovery)
|
|
5194
|
-
let paneMap = {};
|
|
5195
|
-
|
|
5196
|
-
function dispatchTicketId(message) {
|
|
5197
|
-
if (!message || typeof message !== 'object') return null;
|
|
5198
|
-
if (message.ticketId !== undefined && message.ticketId !== null && String(message.ticketId).trim()) {
|
|
5199
|
-
return String(message.ticketId).trim();
|
|
5060
|
+
function publishMissedMessageRequest(client, baseUrl, userId) {
|
|
5061
|
+
if (!userId) {
|
|
5062
|
+
return;
|
|
5200
5063
|
}
|
|
5201
|
-
|
|
5064
|
+
const since = lastMessageTimestampForScope(baseUrl, userId);
|
|
5065
|
+
client.publish({
|
|
5066
|
+
destination: '/app/missed-messages',
|
|
5067
|
+
body: JSON.stringify({
|
|
5068
|
+
userId: String(userId),
|
|
5069
|
+
since
|
|
5070
|
+
})
|
|
5071
|
+
});
|
|
5072
|
+
appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
|
|
5073
|
+
baseUrl,
|
|
5074
|
+
userId,
|
|
5075
|
+
since
|
|
5076
|
+
});
|
|
5202
5077
|
}
|
|
5203
5078
|
|
|
5204
|
-
function
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
if (message && message.taskId !== undefined && message.taskId !== null && String(message.taskId).trim()) {
|
|
5208
|
-
return 'task:' + String(message.taskId).trim();
|
|
5079
|
+
function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
|
|
5080
|
+
if (!messageId) {
|
|
5081
|
+
return;
|
|
5209
5082
|
}
|
|
5210
|
-
|
|
5083
|
+
const primaryUserId = userIds.find(Boolean);
|
|
5084
|
+
const body = primaryUserId
|
|
5085
|
+
? { messageId, userId: String(primaryUserId) }
|
|
5086
|
+
: { messageId };
|
|
5087
|
+
client.publish({
|
|
5088
|
+
destination: '/app/ack',
|
|
5089
|
+
body: JSON.stringify(body)
|
|
5090
|
+
});
|
|
5211
5091
|
}
|
|
5212
5092
|
|
|
5213
|
-
function
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5093
|
+
function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
|
|
5094
|
+
const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
|
|
5095
|
+
const messageId = data.messageId ? String(data.messageId) : null;
|
|
5096
|
+
const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
|
|
5097
|
+
|
|
5098
|
+
recordWebsocketMessage(baseUrl, topic, data, {
|
|
5099
|
+
source,
|
|
5100
|
+
duplicate,
|
|
5101
|
+
accountIds
|
|
5102
|
+
});
|
|
5103
|
+
|
|
5104
|
+
if (messageId) {
|
|
5105
|
+
acknowledgeWebsocketMessage(client, messageId, userIds);
|
|
5220
5106
|
}
|
|
5221
|
-
}
|
|
5222
5107
|
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5108
|
+
updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
|
|
5109
|
+
runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
|
|
5110
|
+
if (runtimeState.websocket[baseUrl].topics[topic]) {
|
|
5111
|
+
runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
|
|
5112
|
+
}
|
|
5227
5113
|
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5114
|
+
if (duplicate) {
|
|
5115
|
+
appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
|
|
5116
|
+
baseUrl,
|
|
5117
|
+
topic,
|
|
5118
|
+
messageId,
|
|
5119
|
+
source
|
|
5120
|
+
});
|
|
5121
|
+
updateRuntimeServiceState();
|
|
5122
|
+
return;
|
|
5234
5123
|
}
|
|
5235
|
-
}
|
|
5236
5124
|
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
execSync('tmux has-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
|
|
5240
|
-
return true;
|
|
5241
|
-
} catch (_) {
|
|
5242
|
-
return false;
|
|
5125
|
+
if (messageId) {
|
|
5126
|
+
rememberProcessedMessageId(messageId);
|
|
5243
5127
|
}
|
|
5244
|
-
}
|
|
5245
5128
|
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
const
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
const lines = output.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
5254
|
-
for (const line of lines) {
|
|
5255
|
-
const [id, dead] = line.split(' ');
|
|
5256
|
-
if (id === paneId) {
|
|
5257
|
-
return dead !== '1';
|
|
5258
|
-
}
|
|
5129
|
+
if (data.type === 'TestSuite') {
|
|
5130
|
+
const payload = data.payload || {};
|
|
5131
|
+
const payloadTaskId = payload.taskId || null;
|
|
5132
|
+
const currentTestTaskId = runtimeState.currentTestTaskId || null;
|
|
5133
|
+
if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
|
|
5134
|
+
updateRuntimeServiceState();
|
|
5135
|
+
return;
|
|
5259
5136
|
}
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5137
|
+
if (payload.tests || payload.name || payload.total != null) {
|
|
5138
|
+
const acctId = payload.accountId || accountIds[0];
|
|
5139
|
+
console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
|
|
5140
|
+
}
|
|
5141
|
+
} else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
|
|
5142
|
+
// A ticket nudge. The hand-off itself is durable on the ticket and is collected by whichever
|
|
5143
|
+
// agent session it was routed to, so nothing is dispatched from here: this only refreshes what
|
|
5144
|
+
// the dashboard shows a human, and records the event for triage.
|
|
5145
|
+
const payload = data.payload || data;
|
|
5146
|
+
const nudgeAccountId = Number(payload.accountId || accountIds[0]) || accountIds[0];
|
|
5147
|
+
console.log('[ws][ticket][account:' + nudgeAccountId + ']', JSON.stringify(payload));
|
|
5148
|
+
appendGlobalActivityLog('ticket', 'nudge.received', {
|
|
5149
|
+
baseUrl,
|
|
5150
|
+
topic,
|
|
5151
|
+
accountId: nudgeAccountId,
|
|
5152
|
+
ticketId: payload.ticketId || null,
|
|
5153
|
+
agentId: payload.agentId || null,
|
|
5154
|
+
messageType: data.type,
|
|
5155
|
+
source,
|
|
5156
|
+
messageId,
|
|
5157
|
+
message: summarizeDispatchMessage(payload)
|
|
5158
|
+
});
|
|
5159
|
+
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5160
|
+
agentSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5263
5161
|
}
|
|
5264
|
-
}
|
|
5265
|
-
|
|
5266
|
-
function killPane(paneId) {
|
|
5267
|
-
try {
|
|
5268
|
-
execSync('tmux kill-pane -t ' + shellQuote(paneId), { stdio: 'ignore' });
|
|
5269
|
-
appendGlobalActivityLog('tmux', 'pane.killed', { paneId });
|
|
5270
|
-
} catch (_) {}
|
|
5271
|
-
}
|
|
5272
5162
|
|
|
5273
|
-
|
|
5274
|
-
if (!tmuxSessionExists()) return;
|
|
5275
|
-
try {
|
|
5276
|
-
execSync('tmux kill-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
|
|
5277
|
-
appendGlobalActivityLog('tmux', 'session.killed', { sessionName: TMUX_SESSION_NAME });
|
|
5278
|
-
} catch (_) {}
|
|
5279
|
-
}
|
|
5280
|
-
|
|
5281
|
-
function configureTmuxSession() {
|
|
5282
|
-
const commands = [
|
|
5283
|
-
'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' mouse on',
|
|
5284
|
-
'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' history-limit 100000',
|
|
5285
|
-
'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' remain-on-exit on',
|
|
5286
|
-
'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' scroll-on-clear on',
|
|
5287
|
-
'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' automatic-rename off'
|
|
5288
|
-
];
|
|
5289
|
-
for (const command of commands) {
|
|
5290
|
-
try {
|
|
5291
|
-
execSync(command, { stdio: 'ignore' });
|
|
5292
|
-
} catch (_) {}
|
|
5293
|
-
}
|
|
5294
|
-
}
|
|
5295
|
-
|
|
5296
|
-
function ensureTmuxSession(accountDir) {
|
|
5297
|
-
if (!isTmuxAvailable()) {
|
|
5298
|
-
appendGlobalActivityLog('tmux', 'session.create_skipped.tmux_unavailable', { accountDir }, 'error');
|
|
5299
|
-
return false;
|
|
5300
|
-
}
|
|
5301
|
-
if (tmuxSessionExists()) return;
|
|
5302
|
-
execSync(
|
|
5303
|
-
'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(accountDir),
|
|
5304
|
-
{ stdio: 'ignore' }
|
|
5305
|
-
);
|
|
5306
|
-
configureTmuxSession();
|
|
5307
|
-
console.log('[listener] Created tmux session:', TMUX_SESSION_NAME);
|
|
5308
|
-
appendGlobalActivityLog('tmux', 'session.created', {
|
|
5309
|
-
sessionName: TMUX_SESSION_NAME,
|
|
5310
|
-
accountDir
|
|
5311
|
-
});
|
|
5312
|
-
return true;
|
|
5313
|
-
}
|
|
5314
|
-
|
|
5315
|
-
function canLaunchTerminalForTmux() {
|
|
5316
|
-
return process.platform === 'darwin';
|
|
5317
|
-
}
|
|
5318
|
-
|
|
5319
|
-
function openTmuxSessionForUser() {
|
|
5320
|
-
if (!isTmuxAvailable()) {
|
|
5321
|
-
throw new Error('tmux is not installed');
|
|
5322
|
-
}
|
|
5323
|
-
|
|
5324
|
-
if (!tmuxSessionExists()) {
|
|
5325
|
-
ensureTmuxSession(os.homedir());
|
|
5326
|
-
}
|
|
5327
|
-
|
|
5328
|
-
const attachCommand = 'tmux attach -t ' + TMUX_SESSION_NAME + ' || tmux new-session -A -s ' + TMUX_SESSION_NAME;
|
|
5329
|
-
|
|
5330
|
-
if (process.platform === 'darwin') {
|
|
5331
|
-
const osaScript = [
|
|
5332
|
-
'tell application "Terminal"',
|
|
5333
|
-
'activate',
|
|
5334
|
-
'do script ' + JSON.stringify(attachCommand),
|
|
5335
|
-
'end tell'
|
|
5336
|
-
].join('\n');
|
|
5337
|
-
execSync('osascript -e ' + shellQuote(osaScript), { stdio: 'ignore' });
|
|
5338
|
-
appendGlobalActivityLog('tmux', 'terminal.opened', {
|
|
5339
|
-
sessionName: TMUX_SESSION_NAME,
|
|
5340
|
-
command: attachCommand
|
|
5341
|
-
});
|
|
5342
|
-
return {
|
|
5343
|
-
success: true,
|
|
5344
|
-
method: 'terminal',
|
|
5345
|
-
command: attachCommand
|
|
5346
|
-
};
|
|
5347
|
-
}
|
|
5348
|
-
|
|
5349
|
-
throw new Error('Opening a terminal window is not supported on this platform yet. Run `' + attachCommand + '` manually.');
|
|
5350
|
-
}
|
|
5351
|
-
|
|
5352
|
-
function dispatchWindowName(accountId, routingKey) {
|
|
5353
|
-
const base = routingKey || ('account:' + accountId) || 'dispatch';
|
|
5354
|
-
return base
|
|
5355
|
-
.replace(/[^a-zA-Z0-9:_-]+/g, '-')
|
|
5356
|
-
.slice(0, 40) || 'dispatch';
|
|
5357
|
-
}
|
|
5358
|
-
|
|
5359
|
-
function resolveAgentCommand(agent, prompt) {
|
|
5360
|
-
const escapedPrompt = prompt.replace(/'/g, "'\"'\"'");
|
|
5361
|
-
// All agents are launched in interactive mode so the user can watch and interact.
|
|
5362
|
-
// claude: positional arg starts interactive session with initial prompt
|
|
5363
|
-
// codex: positional arg starts interactive session with initial prompt
|
|
5364
|
-
// gemini: positional arg starts interactive session with initial prompt
|
|
5365
|
-
switch (agent) {
|
|
5366
|
-
case 'claude':
|
|
5367
|
-
return "claude '" + escapedPrompt + "'";
|
|
5368
|
-
case 'codex':
|
|
5369
|
-
return "codex '" + escapedPrompt + "'";
|
|
5370
|
-
case 'gemini':
|
|
5371
|
-
return "gemini '" + escapedPrompt + "'";
|
|
5372
|
-
default:
|
|
5373
|
-
return "claude '" + escapedPrompt + "'";
|
|
5374
|
-
}
|
|
5375
|
-
}
|
|
5376
|
-
|
|
5377
|
-
function resolveAccountDir(accountId) {
|
|
5378
|
-
if (!fs.existsSync(ACCOUNT_REPO_INDEX_FILE)) return null;
|
|
5379
|
-
try {
|
|
5380
|
-
const index = JSON.parse(fs.readFileSync(ACCOUNT_REPO_INDEX_FILE, 'utf8'));
|
|
5381
|
-
const entry = index[String(accountId)];
|
|
5382
|
-
if (entry && entry.directory && fs.existsSync(entry.directory)) {
|
|
5383
|
-
return entry.directory;
|
|
5384
|
-
}
|
|
5385
|
-
} catch (_) {}
|
|
5386
|
-
return null;
|
|
5387
|
-
}
|
|
5388
|
-
|
|
5389
|
-
function normalizeDispatchAccountId(value) {
|
|
5390
|
-
if (value === undefined || value === null) return null;
|
|
5391
|
-
const trimmed = String(value).trim();
|
|
5392
|
-
return trimmed ? trimmed : null;
|
|
5393
|
-
}
|
|
5394
|
-
|
|
5395
|
-
function resolveDispatchAccountId(message, fallbackAccountId) {
|
|
5396
|
-
const candidates = [
|
|
5397
|
-
normalizeDispatchAccountId(message && message.implementationAccountId),
|
|
5398
|
-
normalizeDispatchAccountId(message && message.accountId),
|
|
5399
|
-
normalizeDispatchAccountId(fallbackAccountId)
|
|
5400
|
-
].filter(Boolean);
|
|
5401
|
-
|
|
5402
|
-
const uniqueCandidates = [];
|
|
5403
|
-
for (const candidate of candidates) {
|
|
5404
|
-
if (!uniqueCandidates.includes(candidate)) {
|
|
5405
|
-
uniqueCandidates.push(candidate);
|
|
5406
|
-
}
|
|
5407
|
-
}
|
|
5408
|
-
|
|
5409
|
-
for (const candidate of uniqueCandidates) {
|
|
5410
|
-
if (resolveAccountDir(candidate)) {
|
|
5411
|
-
return candidate;
|
|
5412
|
-
}
|
|
5413
|
-
}
|
|
5414
|
-
|
|
5415
|
-
return uniqueCandidates[0] || null;
|
|
5416
|
-
}
|
|
5417
|
-
|
|
5418
|
-
function sendKeysToPane(paneId, text) {
|
|
5419
|
-
// Paste through a tmux buffer so multiline follow-ups are injected reliably
|
|
5420
|
-
// into interactive agent UIs, then submit them explicitly.
|
|
5421
|
-
const bufferName = 'remits-cli-' + crypto.randomUUID();
|
|
5422
|
-
try {
|
|
5423
|
-
execSync(
|
|
5424
|
-
'tmux load-buffer -b ' + shellQuote(bufferName) + ' -',
|
|
5425
|
-
{ input: text, stdio: ['pipe', 'ignore', 'ignore'] }
|
|
5426
|
-
);
|
|
5427
|
-
execSync(
|
|
5428
|
-
'tmux paste-buffer -d -b ' + shellQuote(bufferName) + ' -t ' + shellQuote(paneId),
|
|
5429
|
-
{ stdio: 'ignore' }
|
|
5430
|
-
);
|
|
5431
|
-
execSync(
|
|
5432
|
-
'tmux send-keys -t ' + shellQuote(paneId) + ' Enter',
|
|
5433
|
-
{ stdio: 'ignore' }
|
|
5434
|
-
);
|
|
5435
|
-
} finally {
|
|
5436
|
-
try {
|
|
5437
|
-
execSync('tmux delete-buffer -b ' + shellQuote(bufferName), { stdio: 'ignore' });
|
|
5438
|
-
} catch (_) {}
|
|
5439
|
-
}
|
|
5440
|
-
}
|
|
5441
|
-
|
|
5442
|
-
function buildDispatchPrompt(message, options) {
|
|
5443
|
-
const dispatchOptions = options && typeof options === 'object' ? options : {};
|
|
5444
|
-
const textPrompt = message && (message.prompt || message.message);
|
|
5445
|
-
const context = message && typeof message === 'object' ? Object.assign({}, message) : {};
|
|
5446
|
-
delete context.prompt;
|
|
5447
|
-
delete context.message;
|
|
5448
|
-
|
|
5449
|
-
const instructionLines = [
|
|
5450
|
-
'This message was delivered by the remits-cli tool from an inbound platform message.',
|
|
5451
|
-
'When triaging and handling this request, load and use the remits-cli skill.'
|
|
5452
|
-
];
|
|
5453
|
-
|
|
5454
|
-
if (dispatchOptions.noAccountDir === true) {
|
|
5455
|
-
instructionLines.push('remits-cli dispatch status: no_account_dir.');
|
|
5456
|
-
instructionLines.push('The listener could not resolve an account directory from the ticket payload, but you should still handle the request and work with the user to gather any additional account details if needed.');
|
|
5457
|
-
}
|
|
5458
|
-
|
|
5459
|
-
if (textPrompt) {
|
|
5460
|
-
instructionLines.push('');
|
|
5461
|
-
instructionLines.push('Inbound message:');
|
|
5462
|
-
instructionLines.push(textPrompt);
|
|
5463
|
-
}
|
|
5464
|
-
|
|
5465
|
-
if (Object.keys(context).length) {
|
|
5466
|
-
instructionLines.push('');
|
|
5467
|
-
instructionLines.push('Dispatch context:');
|
|
5468
|
-
instructionLines.push('```json');
|
|
5469
|
-
instructionLines.push(JSON.stringify(context, null, 2));
|
|
5470
|
-
instructionLines.push('```');
|
|
5471
|
-
}
|
|
5472
|
-
|
|
5473
|
-
return instructionLines.join('\n');
|
|
5474
|
-
}
|
|
5475
|
-
|
|
5476
|
-
function dispatchToAgent(accountId, message) {
|
|
5477
|
-
const accountDir = resolveAccountDir(accountId);
|
|
5478
|
-
|
|
5479
|
-
if (!isTmuxAvailable()) {
|
|
5480
|
-
console.error('[listener] tmux is not installed. Cannot dispatch agent session.');
|
|
5481
|
-
appendGlobalActivityLog('tmux', 'dispatch.skipped.tmux_unavailable', {
|
|
5482
|
-
accountId,
|
|
5483
|
-
accountDir,
|
|
5484
|
-
message: summarizeDispatchMessage(message)
|
|
5485
|
-
}, 'error');
|
|
5486
|
-
return;
|
|
5487
|
-
}
|
|
5488
|
-
|
|
5489
|
-
const ticketId = dispatchTicketId(message);
|
|
5490
|
-
const routingKey = dispatchRoutingKey(message);
|
|
5491
|
-
const prompt = buildDispatchPrompt(message, {
|
|
5492
|
-
noAccountDir: !accountDir
|
|
5493
|
-
});
|
|
5494
|
-
|
|
5495
|
-
if (!accountDir) {
|
|
5496
|
-
console.error('[listener] No known directory for account', accountId, '- dispatching anyway');
|
|
5497
|
-
appendGlobalActivityLog('tmux', 'dispatch.no_account_dir', {
|
|
5498
|
-
accountId,
|
|
5499
|
-
message: summarizeDispatchMessage(message)
|
|
5500
|
-
}, 'error');
|
|
5501
|
-
}
|
|
5502
|
-
|
|
5503
|
-
// If this ticket already has a tracked pane, check its state
|
|
5504
|
-
if (routingKey && paneMap[routingKey]) {
|
|
5505
|
-
const existingPaneId = paneMap[routingKey];
|
|
5506
|
-
if (tmuxPaneIsAlive(existingPaneId)) {
|
|
5507
|
-
// Pane is alive, so deliver the follow-up to the existing ticket workstream
|
|
5508
|
-
console.log('[listener] Delivering follow-up for', routingKey, 'to existing pane', existingPaneId);
|
|
5509
|
-
appendGlobalActivityLog('tmux', 'dispatch.follow_up', {
|
|
5510
|
-
accountId,
|
|
5511
|
-
accountDir,
|
|
5512
|
-
ticketId,
|
|
5513
|
-
routingKey,
|
|
5514
|
-
paneId: existingPaneId,
|
|
5515
|
-
message: summarizeDispatchMessage(message)
|
|
5516
|
-
});
|
|
5517
|
-
try {
|
|
5518
|
-
sendKeysToPane(existingPaneId, prompt);
|
|
5519
|
-
return;
|
|
5520
|
-
} catch (err) {
|
|
5521
|
-
console.error('[listener] Failed to send to pane', existingPaneId, ':', err.message, '- creating new pane');
|
|
5522
|
-
appendGlobalActivityLog('tmux', 'dispatch.follow_up_failed', {
|
|
5523
|
-
accountId,
|
|
5524
|
-
accountDir,
|
|
5525
|
-
ticketId,
|
|
5526
|
-
routingKey,
|
|
5527
|
-
paneId: existingPaneId,
|
|
5528
|
-
error: err.message,
|
|
5529
|
-
message: summarizeDispatchMessage(message)
|
|
5530
|
-
}, 'error');
|
|
5531
|
-
}
|
|
5532
|
-
}
|
|
5533
|
-
// Pane is dead or gone, so replace it for this ticket/task routing key.
|
|
5534
|
-
console.log('[listener] Pane', existingPaneId, 'for', routingKey, 'is dead or gone - replacing');
|
|
5535
|
-
appendGlobalActivityLog('tmux', 'dispatch.pane_replaced', {
|
|
5536
|
-
accountId,
|
|
5537
|
-
accountDir,
|
|
5538
|
-
ticketId,
|
|
5539
|
-
routingKey,
|
|
5540
|
-
paneId: existingPaneId,
|
|
5541
|
-
message: summarizeDispatchMessage(message)
|
|
5542
|
-
});
|
|
5543
|
-
killPane(existingPaneId);
|
|
5544
|
-
delete paneMap[routingKey];
|
|
5545
|
-
savePaneMap();
|
|
5546
|
-
}
|
|
5547
|
-
|
|
5548
|
-
// Create a new pane for this task
|
|
5549
|
-
try {
|
|
5550
|
-
const paneDir = accountDir || os.homedir();
|
|
5551
|
-
ensureTmuxSession(paneDir);
|
|
5552
|
-
|
|
5553
|
-
const agent = getPreferredAgent();
|
|
5554
|
-
const agentCmd = resolveAgentCommand(agent, prompt);
|
|
5555
|
-
const windowName = dispatchWindowName(accountId, routingKey);
|
|
5556
|
-
|
|
5557
|
-
// Wrap in a login shell so the pane inherits the user's full PATH
|
|
5558
|
-
// (the daemonized listener process has a minimal environment)
|
|
5559
|
-
const userShell = process.env.SHELL || '/bin/zsh';
|
|
5560
|
-
const loginCmd = userShell + ' -l -c ' + shellQuote(agentCmd);
|
|
5561
|
-
|
|
5562
|
-
// Create a dedicated window for this workstream so full-screen agent UIs
|
|
5563
|
-
// remain readable and easy to inspect.
|
|
5564
|
-
const paneInfo = execSync(
|
|
5565
|
-
'tmux new-window -t ' + shellQuote(TMUX_SESSION_NAME) +
|
|
5566
|
-
' -n ' + shellQuote(windowName) +
|
|
5567
|
-
' -c ' + shellQuote(paneDir) +
|
|
5568
|
-
' -P -F ' + shellQuote('#{pane_id}') +
|
|
5569
|
-
' ' + shellQuote(loginCmd),
|
|
5570
|
-
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
5571
|
-
).toString().trim();
|
|
5572
|
-
|
|
5573
|
-
console.log('[listener] Dispatched', routingKey || '(untracked message)', 'to', agent, 'in pane', paneInfo, 'at', accountDir);
|
|
5574
|
-
appendGlobalActivityLog('tmux', 'dispatch.created', {
|
|
5575
|
-
accountId,
|
|
5576
|
-
accountDir: paneDir,
|
|
5577
|
-
resolvedAccountDir: accountDir,
|
|
5578
|
-
ticketId,
|
|
5579
|
-
routingKey,
|
|
5580
|
-
paneId: paneInfo,
|
|
5581
|
-
agent,
|
|
5582
|
-
message: summarizeDispatchMessage(message)
|
|
5583
|
-
});
|
|
5584
|
-
|
|
5585
|
-
if (routingKey) {
|
|
5586
|
-
paneMap[routingKey] = paneInfo;
|
|
5587
|
-
savePaneMap();
|
|
5588
|
-
}
|
|
5589
|
-
} catch (err) {
|
|
5590
|
-
console.error('[listener] Failed to create tmux pane:', err.message);
|
|
5591
|
-
appendGlobalActivityLog('tmux', 'dispatch.create_failed', {
|
|
5592
|
-
accountId,
|
|
5593
|
-
accountDir,
|
|
5594
|
-
ticketId,
|
|
5595
|
-
routingKey,
|
|
5596
|
-
error: err.message,
|
|
5597
|
-
agent: getPreferredAgent(),
|
|
5598
|
-
message: summarizeDispatchMessage(message)
|
|
5599
|
-
}, 'error');
|
|
5600
|
-
}
|
|
5601
|
-
}
|
|
5602
|
-
|
|
5603
|
-
function publishMissedMessageRequest(client, baseUrl, userId) {
|
|
5604
|
-
if (!userId) {
|
|
5605
|
-
return;
|
|
5606
|
-
}
|
|
5607
|
-
const since = lastMessageTimestampForScope(baseUrl, userId);
|
|
5608
|
-
client.publish({
|
|
5609
|
-
destination: '/app/missed-messages',
|
|
5610
|
-
body: JSON.stringify({
|
|
5611
|
-
userId: String(userId),
|
|
5612
|
-
since
|
|
5613
|
-
})
|
|
5614
|
-
});
|
|
5615
|
-
appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
|
|
5616
|
-
baseUrl,
|
|
5617
|
-
userId,
|
|
5618
|
-
since
|
|
5619
|
-
});
|
|
5620
|
-
}
|
|
5621
|
-
|
|
5622
|
-
function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
|
|
5623
|
-
if (!messageId) {
|
|
5624
|
-
return;
|
|
5625
|
-
}
|
|
5626
|
-
const primaryUserId = userIds.find(Boolean);
|
|
5627
|
-
const body = primaryUserId
|
|
5628
|
-
? { messageId, userId: String(primaryUserId) }
|
|
5629
|
-
: { messageId };
|
|
5630
|
-
client.publish({
|
|
5631
|
-
destination: '/app/ack',
|
|
5632
|
-
body: JSON.stringify(body)
|
|
5633
|
-
});
|
|
5634
|
-
}
|
|
5635
|
-
|
|
5636
|
-
function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
|
|
5637
|
-
const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
|
|
5638
|
-
const messageId = data.messageId ? String(data.messageId) : null;
|
|
5639
|
-
const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
|
|
5640
|
-
|
|
5641
|
-
recordWebsocketMessage(baseUrl, topic, data, {
|
|
5642
|
-
source,
|
|
5643
|
-
duplicate,
|
|
5644
|
-
accountIds
|
|
5645
|
-
});
|
|
5646
|
-
|
|
5647
|
-
if (messageId) {
|
|
5648
|
-
acknowledgeWebsocketMessage(client, messageId, userIds);
|
|
5649
|
-
}
|
|
5650
|
-
|
|
5651
|
-
updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
|
|
5652
|
-
runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
|
|
5653
|
-
if (runtimeState.websocket[baseUrl].topics[topic]) {
|
|
5654
|
-
runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
|
|
5655
|
-
}
|
|
5656
|
-
|
|
5657
|
-
if (duplicate) {
|
|
5658
|
-
appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
|
|
5659
|
-
baseUrl,
|
|
5660
|
-
topic,
|
|
5661
|
-
messageId,
|
|
5662
|
-
source
|
|
5663
|
-
});
|
|
5664
|
-
updateRuntimeServiceState();
|
|
5665
|
-
return;
|
|
5666
|
-
}
|
|
5667
|
-
|
|
5668
|
-
if (messageId) {
|
|
5669
|
-
rememberProcessedMessageId(messageId);
|
|
5670
|
-
}
|
|
5671
|
-
|
|
5672
|
-
if (data.type === 'TestSuite') {
|
|
5673
|
-
const payload = data.payload || {};
|
|
5674
|
-
const payloadTaskId = payload.taskId || null;
|
|
5675
|
-
const currentTestTaskId = runtimeState.currentTestTaskId || null;
|
|
5676
|
-
if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
|
|
5677
|
-
updateRuntimeServiceState();
|
|
5678
|
-
return;
|
|
5679
|
-
}
|
|
5680
|
-
if (payload.tests || payload.name || payload.total != null) {
|
|
5681
|
-
const acctId = payload.accountId || accountIds[0];
|
|
5682
|
-
console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
|
|
5683
|
-
}
|
|
5684
|
-
} else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
|
|
5685
|
-
const payload = data.payload || data;
|
|
5686
|
-
const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
|
|
5687
|
-
console.log('[ws][listener][account:' + dispatchAccountId + ']', JSON.stringify(payload));
|
|
5688
|
-
appendGlobalActivityLog('tmux', 'listener_delivery.received', {
|
|
5689
|
-
baseUrl,
|
|
5690
|
-
topic,
|
|
5691
|
-
accountId: dispatchAccountId,
|
|
5692
|
-
messageType: data.type,
|
|
5693
|
-
source,
|
|
5694
|
-
messageId,
|
|
5695
|
-
message: summarizeDispatchMessage(payload)
|
|
5696
|
-
});
|
|
5697
|
-
dispatchToAgent(dispatchAccountId, payload);
|
|
5698
|
-
}
|
|
5699
|
-
|
|
5700
|
-
updateRuntimeServiceState();
|
|
5163
|
+
updateRuntimeServiceState();
|
|
5701
5164
|
}
|
|
5702
5165
|
|
|
5703
5166
|
function createManagedWebsocketClient(baseUrl, urlSessions) {
|
|
@@ -5915,42 +5378,6 @@ async function replaceManagedWebsocketClients(entries, reason) {
|
|
|
5915
5378
|
});
|
|
5916
5379
|
}
|
|
5917
5380
|
|
|
5918
|
-
function pruneDeadPaneMappings() {
|
|
5919
|
-
const removed = [];
|
|
5920
|
-
for (const [routingKey, paneId] of Object.entries(paneMap)) {
|
|
5921
|
-
if (tmuxPaneIsAlive(paneId)) {
|
|
5922
|
-
continue;
|
|
5923
|
-
}
|
|
5924
|
-
delete paneMap[routingKey];
|
|
5925
|
-
removed.push({ routingKey, paneId });
|
|
5926
|
-
}
|
|
5927
|
-
if (removed.length) {
|
|
5928
|
-
savePaneMap();
|
|
5929
|
-
appendGlobalActivityLog('tmux', 'dispatch_state.pruned', {
|
|
5930
|
-
removed
|
|
5931
|
-
});
|
|
5932
|
-
}
|
|
5933
|
-
}
|
|
5934
|
-
|
|
5935
|
-
function ensureTmuxSessionHealthy(defaultDir) {
|
|
5936
|
-
if (!isTmuxAvailable()) {
|
|
5937
|
-
return;
|
|
5938
|
-
}
|
|
5939
|
-
if (tmuxSessionExists()) {
|
|
5940
|
-
pruneDeadPaneMappings();
|
|
5941
|
-
return;
|
|
5942
|
-
}
|
|
5943
|
-
const hadPaneMappings = Object.keys(paneMap).length > 0;
|
|
5944
|
-
if (hadPaneMappings) {
|
|
5945
|
-
paneMap = {};
|
|
5946
|
-
savePaneMap();
|
|
5947
|
-
appendGlobalActivityLog('tmux', 'dispatch_state.cleared.missing_session', {
|
|
5948
|
-
sessionName: TMUX_SESSION_NAME
|
|
5949
|
-
}, 'error');
|
|
5950
|
-
}
|
|
5951
|
-
ensureTmuxSession(defaultDir || os.homedir());
|
|
5952
|
-
}
|
|
5953
|
-
|
|
5954
5381
|
async function runManagedWebsocketWatchdog() {
|
|
5955
5382
|
const now = Date.now();
|
|
5956
5383
|
for (const managedClient of managedClients) {
|
|
@@ -6000,6 +5427,602 @@ async function runManagedWebsocketWatchdog() {
|
|
|
6000
5427
|
}
|
|
6001
5428
|
|
|
6002
5429
|
// --- Persistent WebSocket listener ---
|
|
5430
|
+
// ---------------------------------------------------------------------------------------------
|
|
5431
|
+
// Local agent sessions
|
|
5432
|
+
//
|
|
5433
|
+
// A "CLI agent" is ONE terminal session — one tab running Claude, Codex, or a person — that has
|
|
5434
|
+
// registered itself as available to work support tickets. Three tabs are three agents, each
|
|
5435
|
+
// independently routable and each reporting its own activity.
|
|
5436
|
+
//
|
|
5437
|
+
// Two halves, deliberately separate:
|
|
5438
|
+
// * PRESENCE lives on the platform with a short TTL, refreshed by a heartbeat sidecar that dies
|
|
5439
|
+
// with the tab it belongs to. A closed terminal therefore stops being routable within a couple
|
|
5440
|
+
// of minutes without anyone having to deregister cleanly, and a crash and a quit look the same.
|
|
5441
|
+
// * ASSIGNMENT lives on the ticket. That is why an agent can restart its terminal, or be offline
|
|
5442
|
+
// when work is routed to it, and still find that work waiting.
|
|
5443
|
+
//
|
|
5444
|
+
// Nothing here pushes work at a terminal. An AI agent mid-task cannot receive a push, so it ASKS:
|
|
5445
|
+
// `remits-cli agent work` polls for tickets routed to it. That is the whole delivery mechanism.
|
|
5446
|
+
// ---------------------------------------------------------------------------------------------
|
|
5447
|
+
|
|
5448
|
+
const AGENT_STATE_FILE = path.join(SESSION_DIR, 'agents.json');
|
|
5449
|
+
const AGENT_HEARTBEAT_INTERVAL_MS = Number(process.env.REMITS_CLI_AGENT_HEARTBEAT_MS || 30000);
|
|
5450
|
+
const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5000);
|
|
5451
|
+
const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
|
|
5452
|
+
const AGENT_STATES = ['idle', 'working', 'paused'];
|
|
5453
|
+
|
|
5454
|
+
function readAgentStore() {
|
|
5455
|
+
if (!fs.existsSync(AGENT_STATE_FILE)) {
|
|
5456
|
+
return { agents: {} };
|
|
5457
|
+
}
|
|
5458
|
+
try {
|
|
5459
|
+
const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
|
|
5460
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
5461
|
+
} catch (_) {
|
|
5462
|
+
return { agents: {} };
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
function writeAgentStore(store) {
|
|
5467
|
+
ensureSessionDir();
|
|
5468
|
+
atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
5469
|
+
}
|
|
5470
|
+
|
|
5471
|
+
function processIsAlive(pid) {
|
|
5472
|
+
const numeric = Number(pid);
|
|
5473
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
5474
|
+
return false;
|
|
5475
|
+
}
|
|
5476
|
+
try {
|
|
5477
|
+
process.kill(numeric, 0);
|
|
5478
|
+
return true;
|
|
5479
|
+
} catch (err) {
|
|
5480
|
+
// EPERM means the process exists but belongs to another user — still alive.
|
|
5481
|
+
return err && err.code === 'EPERM';
|
|
5482
|
+
}
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5485
|
+
/**
|
|
5486
|
+
* The locally-known agent sessions, dropping any whose anchor process has gone.
|
|
5487
|
+
*
|
|
5488
|
+
* The store is a local convenience so a follow-up command in the same tab resolves the same agent
|
|
5489
|
+
* without being told its id. It is never the source of truth: the platform's registry is.
|
|
5490
|
+
*/
|
|
5491
|
+
function listLocalAgents() {
|
|
5492
|
+
const store = readAgentStore();
|
|
5493
|
+
const alive = {};
|
|
5494
|
+
let changed = false;
|
|
5495
|
+
for (const [agentId, entry] of Object.entries(store.agents || {})) {
|
|
5496
|
+
if (entry && processIsAlive(entry.anchorPid)) {
|
|
5497
|
+
alive[agentId] = entry;
|
|
5498
|
+
} else {
|
|
5499
|
+
changed = true;
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
if (changed) {
|
|
5503
|
+
writeAgentStore({ agents: alive });
|
|
5504
|
+
}
|
|
5505
|
+
return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
function rememberLocalAgent(entry) {
|
|
5509
|
+
const store = readAgentStore();
|
|
5510
|
+
store.agents = store.agents || {};
|
|
5511
|
+
store.agents[entry.agentId] = entry;
|
|
5512
|
+
writeAgentStore(store);
|
|
5513
|
+
return entry;
|
|
5514
|
+
}
|
|
5515
|
+
|
|
5516
|
+
function forgetLocalAgent(agentId) {
|
|
5517
|
+
const store = readAgentStore();
|
|
5518
|
+
if (store.agents && store.agents[agentId]) {
|
|
5519
|
+
delete store.agents[agentId];
|
|
5520
|
+
writeAgentStore(store);
|
|
5521
|
+
return true;
|
|
5522
|
+
}
|
|
5523
|
+
return false;
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5526
|
+
/**
|
|
5527
|
+
* The process whose death means "this agent session is over".
|
|
5528
|
+
*
|
|
5529
|
+
* Not our own parent: every `remits-cli` invocation from Claude Code, Codex, or a shell script gets
|
|
5530
|
+
* a throwaway shell as its parent, and anchoring to that would kill the heartbeat the instant the
|
|
5531
|
+
* registering command returned. So walk UP the process tree and anchor to the first ancestor that
|
|
5532
|
+
* is recognisably the agent itself, then to the first interactive shell, and only then give up.
|
|
5533
|
+
*/
|
|
5534
|
+
function resolveAgentAnchorPid() {
|
|
5535
|
+
const explicit = Number(process.env.REMITS_AGENT_ANCHOR_PID);
|
|
5536
|
+
if (Number.isFinite(explicit) && explicit > 0 && processIsAlive(explicit)) {
|
|
5537
|
+
return { pid: explicit, source: 'env', command: null };
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
const agentPattern = /(^|\/|\s)(claude|codex|gemini|cursor-agent|aider)(\s|$)/i;
|
|
5541
|
+
const shellPattern = /(^|\/)(zsh|bash|fish|sh|ksh)$/i;
|
|
5542
|
+
let shellCandidate = null;
|
|
5543
|
+
|
|
5544
|
+
try {
|
|
5545
|
+
let pid = process.ppid;
|
|
5546
|
+
for (let depth = 0; depth < 12 && pid && pid > 1; depth += 1) {
|
|
5547
|
+
// pid 1 is launchd/init and never exits. Anchoring to it would register an agent that can never
|
|
5548
|
+
// go offline, so the walk stops short of it and the caller refuses below.
|
|
5549
|
+
|
|
5550
|
+
const line = execFileSync('ps', ['-o', 'ppid=,command=', '-p', String(pid)], {
|
|
5551
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
5552
|
+
}).toString().trim();
|
|
5553
|
+
if (!line) break;
|
|
5554
|
+
const match = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
5555
|
+
if (!match) break;
|
|
5556
|
+
const parentPid = Number(match[1]);
|
|
5557
|
+
const command = match[2];
|
|
5558
|
+
if (agentPattern.test(command)) {
|
|
5559
|
+
return { pid, source: 'agent', command };
|
|
5560
|
+
}
|
|
5561
|
+
if (!shellCandidate && shellPattern.test(String(command).split(/\s+/)[0] || '')) {
|
|
5562
|
+
shellCandidate = { pid, source: 'shell', command };
|
|
5563
|
+
}
|
|
5564
|
+
pid = parentPid;
|
|
5565
|
+
}
|
|
5566
|
+
} catch (_) {
|
|
5567
|
+
// ps is unavailable (or restricted). Fall through to the parent process.
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
if (shellCandidate) {
|
|
5571
|
+
return shellCandidate;
|
|
5572
|
+
}
|
|
5573
|
+
const parent = process.ppid;
|
|
5574
|
+
if (parent && parent > 1 && processIsAlive(parent)) {
|
|
5575
|
+
return { pid: parent, source: 'parent', command: null };
|
|
5576
|
+
}
|
|
5577
|
+
// Nothing mortal to anchor to — typically a detached or orphaned process, whose parent is init.
|
|
5578
|
+
// Registering here would create an agent that never expires and keeps receiving tickets forever,
|
|
5579
|
+
// so refuse instead. Callers with a real session should pass REMITS_AGENT_ANCHOR_PID.
|
|
5580
|
+
return { pid: null, source: 'none', command: null };
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
function agentLabelDefault(anchor, cwd) {
|
|
5584
|
+
const repo = path.basename(cwd || process.cwd());
|
|
5585
|
+
const kind = anchor && anchor.command && /claude/i.test(anchor.command) ? 'claude'
|
|
5586
|
+
: anchor && anchor.command && /codex/i.test(anchor.command) ? 'codex'
|
|
5587
|
+
: anchor && anchor.command && /gemini/i.test(anchor.command) ? 'gemini'
|
|
5588
|
+
: getPreferredAgent();
|
|
5589
|
+
return kind + '@' + repo;
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
/**
|
|
5593
|
+
* Which agent session is THIS command acting as?
|
|
5594
|
+
*
|
|
5595
|
+
* Explicit beats implicit, and ambiguity is an error rather than a guess: routing work to the wrong
|
|
5596
|
+
* tab is silent and confusing, whereas a message naming the candidates is neither.
|
|
5597
|
+
*/
|
|
5598
|
+
function resolveAgentId(flags, options = {}) {
|
|
5599
|
+
const explicit = (flags && (flags['agent-id'] || flags.agentId)) || process.env.REMITS_AGENT_ID;
|
|
5600
|
+
if (explicit) {
|
|
5601
|
+
return String(explicit).trim();
|
|
5602
|
+
}
|
|
5603
|
+
const local = listLocalAgents();
|
|
5604
|
+
if (!local.length) {
|
|
5605
|
+
if (options.optional) return null;
|
|
5606
|
+
throw new Error('No local agent is registered in this session. Run: remits-cli agent register');
|
|
5607
|
+
}
|
|
5608
|
+
const cwd = process.cwd();
|
|
5609
|
+
const inCwd = local.filter((entry) => entry.cwd === cwd);
|
|
5610
|
+
const candidates = inCwd.length ? inCwd : local;
|
|
5611
|
+
if (candidates.length === 1) {
|
|
5612
|
+
return candidates[0].agentId;
|
|
5613
|
+
}
|
|
5614
|
+
throw new Error(
|
|
5615
|
+
'Several local agents are registered. Pass --agent-id (or set REMITS_AGENT_ID). Candidates:\n' +
|
|
5616
|
+
candidates.map((entry) => ' ' + entry.agentId + ' ' + (entry.label || '') + ' ' + entry.cwd).join('\n')
|
|
5617
|
+
);
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
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
|
+
|
|
6003
6026
|
async function listenCommand(flags) {
|
|
6004
6027
|
if (isListenerRunning()) {
|
|
6005
6028
|
const state = readServiceState();
|
|
@@ -6079,43 +6102,15 @@ async function listenCommand(flags) {
|
|
|
6079
6102
|
return;
|
|
6080
6103
|
}
|
|
6081
6104
|
|
|
6082
|
-
// Foreground mode — this is the actual long-running
|
|
6083
|
-
// Clean slate: kill any leftover tmux session and pane state from a previous run
|
|
6084
|
-
killTmuxSession();
|
|
6085
|
-
paneMap = {};
|
|
6086
|
-
savePaneMap();
|
|
6087
|
-
|
|
6105
|
+
// Foreground mode — this is the actual long-running service
|
|
6088
6106
|
writeListenerPid();
|
|
6089
6107
|
runtimeState.service.startedAt = new Date().toISOString();
|
|
6090
6108
|
runtimeState.service.pid = process.pid;
|
|
6091
6109
|
recordDiscoverySummary(discoverAccountRepos());
|
|
6092
6110
|
const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
|
|
6093
6111
|
await startDashboardServer(dashboardPort);
|
|
6094
|
-
await bestEffortRegisterCliListeners({ reason: 'listener.start' });
|
|
6095
|
-
let lastListenerRegistrationAt = Date.now();
|
|
6096
6112
|
|
|
6097
|
-
//
|
|
6098
|
-
try {
|
|
6099
|
-
const defaultDir = os.homedir();
|
|
6100
|
-
execSync(
|
|
6101
|
-
'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(defaultDir),
|
|
6102
|
-
{ stdio: 'ignore' }
|
|
6103
|
-
);
|
|
6104
|
-
configureTmuxSession();
|
|
6105
|
-
appendGlobalActivityLog('tmux', 'session.created', {
|
|
6106
|
-
sessionName: TMUX_SESSION_NAME,
|
|
6107
|
-
accountDir: defaultDir,
|
|
6108
|
-
source: 'listener.start'
|
|
6109
|
-
});
|
|
6110
|
-
} catch (err) {
|
|
6111
|
-
console.error('[listener] Failed to create tmux session:', err.message);
|
|
6112
|
-
appendGlobalActivityLog('tmux', 'session.create_failed', {
|
|
6113
|
-
sessionName: TMUX_SESSION_NAME,
|
|
6114
|
-
error: err.message
|
|
6115
|
-
}, 'error');
|
|
6116
|
-
}
|
|
6117
|
-
|
|
6118
|
-
// Clean up on exit — kill tmux session and clear state
|
|
6113
|
+
// Clean up on exit
|
|
6119
6114
|
let refreshInterval = null;
|
|
6120
6115
|
const cleanup = () => {
|
|
6121
6116
|
appendGlobalActivityLog('listener', 'cleanup', { pid: process.pid });
|
|
@@ -6136,9 +6131,6 @@ async function listenCommand(flags) {
|
|
|
6136
6131
|
} catch (_) {}
|
|
6137
6132
|
dashboardServer = null;
|
|
6138
6133
|
}
|
|
6139
|
-
killTmuxSession();
|
|
6140
|
-
paneMap = {};
|
|
6141
|
-
savePaneMap();
|
|
6142
6134
|
clearListenerPid();
|
|
6143
6135
|
clearServiceState();
|
|
6144
6136
|
process.exit(0);
|
|
@@ -6151,7 +6143,7 @@ async function listenCommand(flags) {
|
|
|
6151
6143
|
appendGlobalActivityLog('listener', 'foreground.started', {
|
|
6152
6144
|
pid: process.pid,
|
|
6153
6145
|
accountCount: entries.length,
|
|
6154
|
-
logFile:
|
|
6146
|
+
logFile: ACTIVITY_LOG_FILE,
|
|
6155
6147
|
dashboardUrl: runtimeState.service.dashboardUrl
|
|
6156
6148
|
});
|
|
6157
6149
|
|
|
@@ -6166,8 +6158,6 @@ async function listenCommand(flags) {
|
|
|
6166
6158
|
}
|
|
6167
6159
|
maintenanceInFlight = true;
|
|
6168
6160
|
try {
|
|
6169
|
-
ensureTmuxSessionHealthy(os.homedir());
|
|
6170
|
-
|
|
6171
6161
|
const currentEntries = listenerSessionEntriesFromStore(readAllSessions());
|
|
6172
6162
|
const currentSignature = listenerSessionSignature(currentEntries);
|
|
6173
6163
|
if (currentSignature !== activeSessionSignature) {
|
|
@@ -6180,11 +6170,6 @@ async function listenCommand(flags) {
|
|
|
6180
6170
|
activeSessionSignature = currentSignature;
|
|
6181
6171
|
}
|
|
6182
6172
|
|
|
6183
|
-
if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
|
|
6184
|
-
await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
|
|
6185
|
-
lastListenerRegistrationAt = Date.now();
|
|
6186
|
-
}
|
|
6187
|
-
|
|
6188
6173
|
await runManagedWebsocketWatchdog();
|
|
6189
6174
|
updateRuntimeServiceState();
|
|
6190
6175
|
} catch (err) {
|
|
@@ -6218,17 +6203,6 @@ async function listenStopCommand() {
|
|
|
6218
6203
|
appendGlobalActivityLog('listener', 'stop.no_process');
|
|
6219
6204
|
}
|
|
6220
6205
|
|
|
6221
|
-
// Kill the tmux session and clear pane tracking
|
|
6222
|
-
if (tmuxSessionExists()) {
|
|
6223
|
-
killTmuxSession();
|
|
6224
|
-
console.log('Killed tmux session:', TMUX_SESSION_NAME);
|
|
6225
|
-
}
|
|
6226
|
-
if (fs.existsSync(DISPATCH_STATE_FILE)) {
|
|
6227
|
-
atomicWriteFile(DISPATCH_STATE_FILE, '{}');
|
|
6228
|
-
appendGlobalActivityLog('tmux', 'dispatch_state.cleared', {
|
|
6229
|
-
file: DISPATCH_STATE_FILE
|
|
6230
|
-
});
|
|
6231
|
-
}
|
|
6232
6206
|
clearServiceState();
|
|
6233
6207
|
}
|
|
6234
6208
|
|
|
@@ -6571,6 +6545,14 @@ async function main() {
|
|
|
6571
6545
|
const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
|
|
6572
6546
|
const wantsJson = flagEnabled(args.json);
|
|
6573
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
|
+
|
|
6574
6556
|
if (shouldAutoUpdate(command, args)) {
|
|
6575
6557
|
const requireSuccessfulAutoUpdate = command === 'start';
|
|
6576
6558
|
if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
|
|
@@ -6578,13 +6560,9 @@ async function main() {
|
|
|
6578
6560
|
|
|
6579
6561
|
// Best-effort: update account-repo index whenever running from inside a remits repo
|
|
6580
6562
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
6581
|
-
if (!wantsJson) {
|
|
6582
|
-
try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
|
|
6583
|
-
}
|
|
6584
|
-
|
|
6585
6563
|
// Auto-start the background service if not already running.
|
|
6586
6564
|
// Skip for lifecycle subcommands and help.
|
|
6587
|
-
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';
|
|
6588
6566
|
const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
|
|
6589
6567
|
if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
|
|
6590
6568
|
try {
|
|
@@ -6611,7 +6589,12 @@ async function main() {
|
|
|
6611
6589
|
console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
|
|
6612
6590
|
console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
|
|
6613
6591
|
console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
|
|
6614
|
-
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');
|
|
6615
6598
|
console.log(' remits-cli stop');
|
|
6616
6599
|
console.log(' remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
|
|
6617
6600
|
console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
|
|
@@ -6760,6 +6743,15 @@ async function main() {
|
|
|
6760
6743
|
return;
|
|
6761
6744
|
}
|
|
6762
6745
|
|
|
6746
|
+
if (command === 'agent') {
|
|
6747
|
+
if (wantsHelp) {
|
|
6748
|
+
printAgentHelp();
|
|
6749
|
+
process.exit(0);
|
|
6750
|
+
}
|
|
6751
|
+
await agentCommand(args, subcommand);
|
|
6752
|
+
return;
|
|
6753
|
+
}
|
|
6754
|
+
|
|
6763
6755
|
if (command === 'install') {
|
|
6764
6756
|
await installSkillsCommand(args);
|
|
6765
6757
|
return;
|
|
@@ -6789,6 +6781,9 @@ async function main() {
|
|
|
6789
6781
|
}
|
|
6790
6782
|
|
|
6791
6783
|
main().catch((err) => {
|
|
6784
|
+
if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
|
|
6785
|
+
console.error(err.stack);
|
|
6786
|
+
}
|
|
6792
6787
|
console.error('remits-cli error:', describeError(err) + sandboxNetworkHint(err));
|
|
6793
6788
|
process.exit(1);
|
|
6794
6789
|
});
|