@remits/remits-cli 0.1.108 → 0.1.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +953 -897
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +180 -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,663 @@ async function runManagedWebsocketWatchdog() {
|
|
|
6000
5427
|
}
|
|
6001
5428
|
|
|
6002
5429
|
// --- Persistent WebSocket listener ---
|
|
5430
|
+
// ---------------------------------------------------------------------------------------------
|
|
5431
|
+
// Local agent sessions
|
|
5432
|
+
//
|
|
5433
|
+
// A "CLI agent" is ONE terminal session — one tab running Claude, Codex, or a person — that has
|
|
5434
|
+
// registered itself as available to work support tickets. Three tabs are three agents, each
|
|
5435
|
+
// independently routable and each reporting its own activity.
|
|
5436
|
+
//
|
|
5437
|
+
// Two halves, deliberately separate:
|
|
5438
|
+
// * PRESENCE lives on the platform with a short TTL, refreshed by a heartbeat sidecar that dies
|
|
5439
|
+
// with the tab it belongs to. A closed terminal therefore stops being routable within a couple
|
|
5440
|
+
// of minutes without anyone having to deregister cleanly, and a crash and a quit look the same.
|
|
5441
|
+
// * ASSIGNMENT lives on the ticket. That is why an agent can restart its terminal, or be offline
|
|
5442
|
+
// when work is routed to it, and still find that work waiting.
|
|
5443
|
+
//
|
|
5444
|
+
// Nothing here pushes work at a terminal. An AI agent mid-task cannot receive a push, so it ASKS:
|
|
5445
|
+
// `remits-cli agent work` polls for tickets routed to it. That is the whole delivery mechanism.
|
|
5446
|
+
// ---------------------------------------------------------------------------------------------
|
|
5447
|
+
|
|
5448
|
+
const AGENT_STATE_FILE = path.join(SESSION_DIR, 'agents.json');
|
|
5449
|
+
const AGENT_HEARTBEAT_INTERVAL_MS = Number(process.env.REMITS_CLI_AGENT_HEARTBEAT_MS || 30000);
|
|
5450
|
+
const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5000);
|
|
5451
|
+
const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
|
|
5452
|
+
const AGENT_STATES = ['idle', 'working', 'paused'];
|
|
5453
|
+
|
|
5454
|
+
function readAgentStore() {
|
|
5455
|
+
if (!fs.existsSync(AGENT_STATE_FILE)) {
|
|
5456
|
+
return { agents: {} };
|
|
5457
|
+
}
|
|
5458
|
+
try {
|
|
5459
|
+
const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
|
|
5460
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
5461
|
+
} catch (_) {
|
|
5462
|
+
return { agents: {} };
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
function writeAgentStore(store) {
|
|
5467
|
+
ensureSessionDir();
|
|
5468
|
+
atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
5469
|
+
}
|
|
5470
|
+
|
|
5471
|
+
function processIsAlive(pid) {
|
|
5472
|
+
const numeric = Number(pid);
|
|
5473
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
5474
|
+
return false;
|
|
5475
|
+
}
|
|
5476
|
+
try {
|
|
5477
|
+
process.kill(numeric, 0);
|
|
5478
|
+
return true;
|
|
5479
|
+
} catch (err) {
|
|
5480
|
+
// EPERM means the process exists but belongs to another user — still alive.
|
|
5481
|
+
return err && err.code === 'EPERM';
|
|
5482
|
+
}
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5485
|
+
/**
|
|
5486
|
+
* The locally-known agent sessions, dropping any whose anchor process has gone.
|
|
5487
|
+
*
|
|
5488
|
+
* The store is a local convenience so a follow-up command in the same tab resolves the same agent
|
|
5489
|
+
* without being told its id. It is never the source of truth: the platform's registry is.
|
|
5490
|
+
*/
|
|
5491
|
+
function listLocalAgents() {
|
|
5492
|
+
const store = readAgentStore();
|
|
5493
|
+
const alive = {};
|
|
5494
|
+
let changed = false;
|
|
5495
|
+
for (const [agentId, entry] of Object.entries(store.agents || {})) {
|
|
5496
|
+
if (entry && processIsAlive(entry.anchorPid)) {
|
|
5497
|
+
alive[agentId] = entry;
|
|
5498
|
+
} else {
|
|
5499
|
+
changed = true;
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
if (changed) {
|
|
5503
|
+
writeAgentStore({ agents: alive });
|
|
5504
|
+
}
|
|
5505
|
+
return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
function rememberLocalAgent(entry) {
|
|
5509
|
+
const store = readAgentStore();
|
|
5510
|
+
store.agents = store.agents || {};
|
|
5511
|
+
store.agents[entry.agentId] = entry;
|
|
5512
|
+
writeAgentStore(store);
|
|
5513
|
+
return entry;
|
|
5514
|
+
}
|
|
5515
|
+
|
|
5516
|
+
function forgetLocalAgent(agentId) {
|
|
5517
|
+
const store = readAgentStore();
|
|
5518
|
+
if (store.agents && store.agents[agentId]) {
|
|
5519
|
+
delete store.agents[agentId];
|
|
5520
|
+
writeAgentStore(store);
|
|
5521
|
+
return true;
|
|
5522
|
+
}
|
|
5523
|
+
return false;
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5526
|
+
/**
|
|
5527
|
+
* The process whose death means "this agent session is over".
|
|
5528
|
+
*
|
|
5529
|
+
* Not our own parent: every `remits-cli` invocation from Claude Code, Codex, or a shell script gets
|
|
5530
|
+
* a throwaway shell as its parent, and anchoring to that would kill the heartbeat the instant the
|
|
5531
|
+
* registering command returned. So walk UP the process tree and anchor to the first ancestor that
|
|
5532
|
+
* is recognisably the agent itself, then to the first interactive shell, and only then give up.
|
|
5533
|
+
*/
|
|
5534
|
+
function resolveAgentAnchorPid() {
|
|
5535
|
+
const explicit = Number(process.env.REMITS_AGENT_ANCHOR_PID);
|
|
5536
|
+
if (Number.isFinite(explicit) && explicit > 0 && processIsAlive(explicit)) {
|
|
5537
|
+
return { pid: explicit, source: 'env', command: null };
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
const agentPattern = /(^|\/|\s)(claude|codex|gemini|cursor-agent|aider)(\s|$)/i;
|
|
5541
|
+
const shellPattern = /(^|\/)(zsh|bash|fish|sh|ksh)$/i;
|
|
5542
|
+
let shellCandidate = null;
|
|
5543
|
+
|
|
5544
|
+
try {
|
|
5545
|
+
let pid = process.ppid;
|
|
5546
|
+
for (let depth = 0; depth < 12 && pid && pid > 1; depth += 1) {
|
|
5547
|
+
// pid 1 is launchd/init and never exits. Anchoring to it would register an agent that can never
|
|
5548
|
+
// go offline, so the walk stops short of it and the caller refuses below.
|
|
5549
|
+
|
|
5550
|
+
const line = execFileSync('ps', ['-o', 'ppid=,command=', '-p', String(pid)], {
|
|
5551
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
5552
|
+
}).toString().trim();
|
|
5553
|
+
if (!line) break;
|
|
5554
|
+
const match = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
5555
|
+
if (!match) break;
|
|
5556
|
+
const parentPid = Number(match[1]);
|
|
5557
|
+
const command = match[2];
|
|
5558
|
+
if (agentPattern.test(command)) {
|
|
5559
|
+
return { pid, source: 'agent', command };
|
|
5560
|
+
}
|
|
5561
|
+
if (!shellCandidate && shellPattern.test(String(command).split(/\s+/)[0] || '')) {
|
|
5562
|
+
shellCandidate = { pid, source: 'shell', command };
|
|
5563
|
+
}
|
|
5564
|
+
pid = parentPid;
|
|
5565
|
+
}
|
|
5566
|
+
} catch (_) {
|
|
5567
|
+
// ps is unavailable (or restricted). Fall through to the parent process.
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
if (shellCandidate) {
|
|
5571
|
+
return shellCandidate;
|
|
5572
|
+
}
|
|
5573
|
+
const parent = process.ppid;
|
|
5574
|
+
if (parent && parent > 1 && processIsAlive(parent)) {
|
|
5575
|
+
return { pid: parent, source: 'parent', command: null };
|
|
5576
|
+
}
|
|
5577
|
+
// Nothing mortal to anchor to — typically a detached or orphaned process, whose parent is init.
|
|
5578
|
+
// Registering here would create an agent that never expires and keeps receiving tickets forever,
|
|
5579
|
+
// so refuse instead. Callers with a real session should pass REMITS_AGENT_ANCHOR_PID.
|
|
5580
|
+
return { pid: null, source: 'none', command: null };
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
function agentLabelDefault(anchor, cwd) {
|
|
5584
|
+
const repo = path.basename(cwd || process.cwd());
|
|
5585
|
+
const kind = anchor && anchor.command && /claude/i.test(anchor.command) ? 'claude'
|
|
5586
|
+
: anchor && anchor.command && /codex/i.test(anchor.command) ? 'codex'
|
|
5587
|
+
: anchor && anchor.command && /gemini/i.test(anchor.command) ? 'gemini'
|
|
5588
|
+
: getPreferredAgent();
|
|
5589
|
+
return kind + '@' + repo;
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
/**
|
|
5593
|
+
* Which agent session is THIS command acting as?
|
|
5594
|
+
*
|
|
5595
|
+
* Explicit beats implicit, and ambiguity is an error rather than a guess: routing work to the wrong
|
|
5596
|
+
* tab is silent and confusing, whereas a message naming the candidates is neither.
|
|
5597
|
+
*/
|
|
5598
|
+
function resolveAgentId(flags, options = {}) {
|
|
5599
|
+
const explicit = (flags && (flags['agent-id'] || flags.agentId)) || process.env.REMITS_AGENT_ID;
|
|
5600
|
+
if (explicit) {
|
|
5601
|
+
return String(explicit).trim();
|
|
5602
|
+
}
|
|
5603
|
+
const local = listLocalAgents();
|
|
5604
|
+
if (!local.length) {
|
|
5605
|
+
if (options.optional) return null;
|
|
5606
|
+
throw new Error('No local agent is registered in this session. Run: remits-cli agent register');
|
|
5607
|
+
}
|
|
5608
|
+
const cwd = process.cwd();
|
|
5609
|
+
const inCwd = local.filter((entry) => entry.cwd === cwd);
|
|
5610
|
+
const candidates = inCwd.length ? inCwd : local;
|
|
5611
|
+
if (candidates.length === 1) {
|
|
5612
|
+
return candidates[0].agentId;
|
|
5613
|
+
}
|
|
5614
|
+
throw new Error(
|
|
5615
|
+
'Several local agents are registered. Pass --agent-id (or set REMITS_AGENT_ID). Candidates:\n' +
|
|
5616
|
+
candidates.map((entry) => ' ' + entry.agentId + ' ' + (entry.label || '') + ' ' + entry.cwd).join('\n')
|
|
5617
|
+
);
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
/**
|
|
5621
|
+
* The host and lane a support agent registers against when it is told nothing.
|
|
5622
|
+
*
|
|
5623
|
+
* An agent works REAL support tickets, and it must be startable from any directory on the machine —
|
|
5624
|
+
* not only from inside an account repo. So it defaults to the platform host the CLI ships with
|
|
5625
|
+
* (`REMITS_BASE_URL` when set) and to the `prod` lane.
|
|
5626
|
+
*
|
|
5627
|
+
* That is a deliberate exception to the CLI's test-first default, which exists to stop a developer
|
|
5628
|
+
* ITERATING from touching live data. Registering presence mutates no business data, and a test-lane
|
|
5629
|
+
* agent is silently useless for support: it registers, appears online, and can never be routed a
|
|
5630
|
+
* production ticket. Ask for `--data-mode test` when you want a fixture agent.
|
|
5631
|
+
*/
|
|
5632
|
+
function agentDefaults(flags, entry) {
|
|
5633
|
+
return {
|
|
5634
|
+
baseUrl: normalizeBaseUrl(
|
|
5635
|
+
(flags && flags['base-url']) || (entry && entry.baseUrl) || DEFAULT_BASE_URL),
|
|
5636
|
+
dataMode: normalizeDataMode(
|
|
5637
|
+
(flags && (flags['data-mode'] || flags.dataMode)) || (entry && entry.dataMode) || 'prod')
|
|
5638
|
+
};
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
function agentSessionFor(flags, entry) {
|
|
5642
|
+
const cwd = (entry && entry.cwd) || process.cwd();
|
|
5643
|
+
const defaults = agentDefaults(flags, entry);
|
|
5644
|
+
const context = resolveSessionContext(cwd, Object.assign({}, flags, {
|
|
5645
|
+
'base-url': defaults.baseUrl,
|
|
5646
|
+
'data-mode': defaults.dataMode
|
|
5647
|
+
}));
|
|
5648
|
+
if (!context.session || !context.session.token) {
|
|
5649
|
+
throw new Error('No authenticated session for ' + defaults.baseUrl +
|
|
5650
|
+
'. Run: remits-cli auth --base-url ' + defaults.baseUrl);
|
|
5651
|
+
}
|
|
5652
|
+
// resolveSessionContext names it `resolvedBaseUrl` and resolves no data lane, so normalize both here
|
|
5653
|
+
// rather than letting each caller re-derive them. Destructuring `{ baseUrl, dataMode }` off it yields
|
|
5654
|
+
// undefined silently, and the failure then surfaces far away as an axios "Invalid URL".
|
|
5655
|
+
return {
|
|
5656
|
+
session: context.session,
|
|
5657
|
+
accountId: context.accountId,
|
|
5658
|
+
baseUrl: defaults.baseUrl,
|
|
5659
|
+
dataMode: defaults.dataMode,
|
|
5660
|
+
sessionResolutionWarning: context.sessionResolutionWarning
|
|
5661
|
+
};
|
|
5662
|
+
}
|
|
5663
|
+
|
|
5664
|
+
/** Every account repo checked out on this machine, as registration claims. The platform verifies them. */
|
|
5665
|
+
function agentAccountClaims(cwd) {
|
|
5666
|
+
const claims = listAccountRepoEntries(readAccountRepoIndex()).map((entry) => ({
|
|
5667
|
+
accountId: Number(entry.accountId),
|
|
5668
|
+
name: entry.name || null,
|
|
5669
|
+
type: entry.type || null,
|
|
5670
|
+
platformAccountId: entry.platformAccountId || null,
|
|
5671
|
+
directory: entry.directory || null,
|
|
5672
|
+
routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
|
|
5673
|
+
? 'self_and_descendants'
|
|
5674
|
+
: 'self',
|
|
5675
|
+
source: entry.source || 'repo'
|
|
5676
|
+
})).filter((entry) => Number.isFinite(entry.accountId) && entry.accountId > 0);
|
|
5677
|
+
|
|
5678
|
+
// The repo the command was run from leads, so a tab working one account is routed that account's
|
|
5679
|
+
// tickets first even when the machine has a dozen checkouts.
|
|
5680
|
+
try {
|
|
5681
|
+
const here = loadAccountId(cwd);
|
|
5682
|
+
const index = claims.findIndex((entry) => entry.accountId === Number(here));
|
|
5683
|
+
if (index > 0) {
|
|
5684
|
+
claims.unshift(claims.splice(index, 1)[0]);
|
|
5685
|
+
}
|
|
5686
|
+
} catch (_) { /* not in an account repo */ }
|
|
5687
|
+
return claims;
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
async function agentRegisterCommand(flags, options = {}) {
|
|
5691
|
+
const cwd = process.cwd();
|
|
5692
|
+
try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
|
|
5693
|
+
try { discoverAccountRepos(); } catch (_) { /* best-effort */ }
|
|
5694
|
+
|
|
5695
|
+
const { session, baseUrl, dataMode } = agentSessionFor(flags, null);
|
|
5696
|
+
const anchor = resolveAgentAnchorPid();
|
|
5697
|
+
if (!anchor.pid) {
|
|
5698
|
+
throw new Error(
|
|
5699
|
+
'Could not find a process to anchor this agent to, so registering would create an agent that ' +
|
|
5700
|
+
'never goes offline. Run this from an interactive terminal, or set REMITS_AGENT_ANCHOR_PID to ' +
|
|
5701
|
+
'the pid whose exit should end this agent session.');
|
|
5702
|
+
}
|
|
5703
|
+
const explicitId = (flags['agent-id'] || flags.agentId || process.env.REMITS_AGENT_ID || '').trim();
|
|
5704
|
+
// Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
|
|
5705
|
+
// to let an unrelated record be adopted whenever two registrations shared an anchor.
|
|
5706
|
+
const existingId = explicitId ||
|
|
5707
|
+
(listLocalAgents().find((entry) =>
|
|
5708
|
+
entry.cwd === cwd && entry.anchorPid === anchor.pid && entry.anchorSource === anchor.source) || {}).agentId ||
|
|
5709
|
+
null;
|
|
5710
|
+
|
|
5711
|
+
const api = buildAxios(baseUrl, session.token, 15000);
|
|
5712
|
+
const payload = {
|
|
5713
|
+
agentId: existingId || undefined,
|
|
5714
|
+
label: flags.label || agentLabelDefault(anchor, cwd),
|
|
5715
|
+
agentType: flags.agent || getPreferredAgent(),
|
|
5716
|
+
cliVersion: readCliVersion(),
|
|
5717
|
+
host: os.hostname(),
|
|
5718
|
+
pid: anchor.pid,
|
|
5719
|
+
cwd,
|
|
5720
|
+
dataMode,
|
|
5721
|
+
accountId: Number(session.accountId) || undefined,
|
|
5722
|
+
accounts: agentAccountClaims(cwd),
|
|
5723
|
+
state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
|
|
5724
|
+
activity: flags.activity || null,
|
|
5725
|
+
source: flags.source || 'repo'
|
|
5726
|
+
};
|
|
5727
|
+
|
|
5728
|
+
const response = await api.post('/cli/agentRegister', payload).then((r) => r.data);
|
|
5729
|
+
if (!response || response.success === false) {
|
|
5730
|
+
throw new Error((response && response.message) || 'Agent registration was refused.');
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
const entry = rememberLocalAgent({
|
|
5734
|
+
agentId: response.agentId,
|
|
5735
|
+
label: payload.label,
|
|
5736
|
+
cwd,
|
|
5737
|
+
baseUrl,
|
|
5738
|
+
dataMode,
|
|
5739
|
+
// The accounts the platform ACCEPTED, so `agent list` can scope itself to the work this session
|
|
5740
|
+
// can actually be routed rather than to whichever session happened to be most recent.
|
|
5741
|
+
accountIds: response.accountIds || [],
|
|
5742
|
+
accountId: Number(session.accountId) || null,
|
|
5743
|
+
anchorPid: anchor.pid,
|
|
5744
|
+
anchorSource: anchor.source,
|
|
5745
|
+
registeredAtMs: Date.now()
|
|
5746
|
+
});
|
|
5747
|
+
|
|
5748
|
+
// A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
|
|
5749
|
+
// re-registering, and the daemons multiply for as long as the platform stays unreachable.
|
|
5750
|
+
if (options.spawnHeartbeat !== false) {
|
|
5751
|
+
startAgentHeartbeatDaemon(entry);
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
if (flagEnabled(flags.json)) {
|
|
5755
|
+
console.log(JSON.stringify({ ...response, local: entry }, null, 2));
|
|
5756
|
+
return;
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
const accepted = response.accountIds || [];
|
|
5760
|
+
console.log('Registered as a support agent: ' + response.agentId);
|
|
5761
|
+
console.log(' label ' + payload.label);
|
|
5762
|
+
console.log(' platform ' + baseUrl);
|
|
5763
|
+
console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (live support tickets)' : ' (fixtures only)'));
|
|
5764
|
+
console.log(' accounts ' + accepted.length + ' account(s) this session can be routed work for');
|
|
5765
|
+
console.log(' ' + accepted.join(', '));
|
|
5766
|
+
console.log(' heartbeat every ' + Math.round(AGENT_HEARTBEAT_INTERVAL_MS / 1000) + 's, anchored to pid ' +
|
|
5767
|
+
anchor.pid + ' (' + anchor.source + '), ends when this session ends');
|
|
5768
|
+
if ((response.skippedAccounts || []).length) {
|
|
5769
|
+
console.log(' refused ' + response.skippedAccounts
|
|
5770
|
+
.map((entry) => entry.accountId + ' (' + entry.reason + ')').join(', '));
|
|
5771
|
+
}
|
|
5772
|
+
if (dataMode !== 'prod') {
|
|
5773
|
+
console.log('');
|
|
5774
|
+
console.log('NOTE: a ' + dataMode + '-lane agent never receives production tickets. Re-register with');
|
|
5775
|
+
console.log(' --data-mode prod to take real support work.');
|
|
5776
|
+
}
|
|
5777
|
+
console.log('');
|
|
5778
|
+
console.log('You are on duty. The support loop, repeated for as long as you are working:');
|
|
5779
|
+
console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
|
|
5780
|
+
console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
|
|
5781
|
+
console.log(' 3. work the ticket; keep step 2 current as you change what you are doing');
|
|
5782
|
+
console.log(' 4. close it out via mcp_support_ticket (accept -> update_status -> complete)');
|
|
5783
|
+
console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
|
|
5784
|
+
}
|
|
5785
|
+
|
|
5786
|
+
function readCliVersion() {
|
|
5787
|
+
try {
|
|
5788
|
+
return require(path.join(__dirname, 'package.json')).version || null;
|
|
5789
|
+
} catch (_) {
|
|
5790
|
+
return null;
|
|
5791
|
+
}
|
|
5792
|
+
}
|
|
5793
|
+
|
|
5794
|
+
/**
|
|
5795
|
+
* Spawn the detached heartbeat that keeps this session visible.
|
|
5796
|
+
*
|
|
5797
|
+
* It is a separate process on purpose: the registering command must return immediately so the agent
|
|
5798
|
+
* can get on with its work, and presence must continue between commands — an agent that only
|
|
5799
|
+
* appeared online while it happened to be running remits-cli would be invisible for exactly the
|
|
5800
|
+
* period it was busy working a ticket.
|
|
5801
|
+
*/
|
|
5802
|
+
function startAgentHeartbeatDaemon(entry) {
|
|
5803
|
+
const { spawn } = require('child_process');
|
|
5804
|
+
const child = spawn(process.execPath, [
|
|
5805
|
+
__filename, 'agent', 'heartbeat-daemon',
|
|
5806
|
+
'--agent-id', entry.agentId,
|
|
5807
|
+
'--anchor-pid', String(entry.anchorPid),
|
|
5808
|
+
'--base-url', entry.baseUrl,
|
|
5809
|
+
'--cwd', entry.cwd
|
|
5810
|
+
], { detached: true, stdio: 'ignore', env: { ...process.env } });
|
|
5811
|
+
child.unref();
|
|
5812
|
+
appendGlobalActivityLog('agent', 'heartbeat.started', {
|
|
5813
|
+
agentId: entry.agentId,
|
|
5814
|
+
anchorPid: entry.anchorPid,
|
|
5815
|
+
heartbeatPid: child.pid
|
|
5816
|
+
});
|
|
5817
|
+
return child.pid;
|
|
5818
|
+
}
|
|
5819
|
+
|
|
5820
|
+
async function agentHeartbeatDaemonCommand(flags) {
|
|
5821
|
+
const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
|
|
5822
|
+
const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
|
|
5823
|
+
const cwd = flags.cwd || process.cwd();
|
|
5824
|
+
if (!agentId || !Number.isFinite(anchorPid)) {
|
|
5825
|
+
throw new Error('agent heartbeat-daemon requires --agent-id and --anchor-pid.');
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
// The daemon runs detached, so its own process tree says nothing about the session it is watching.
|
|
5829
|
+
// Re-registration must reuse the anchor it was given, never re-resolve one.
|
|
5830
|
+
process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
|
|
5831
|
+
|
|
5832
|
+
const stop = async (reason) => {
|
|
5833
|
+
appendGlobalActivityLog('agent', 'heartbeat.stopped', { agentId, anchorPid, reason });
|
|
5834
|
+
forgetLocalAgent(agentId);
|
|
5835
|
+
try {
|
|
5836
|
+
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
5837
|
+
await buildAxios(context.baseUrl, context.session.token, 5000)
|
|
5838
|
+
.post('/cli/agentDeregister', { agentId });
|
|
5839
|
+
} catch (_) {
|
|
5840
|
+
// A courtesy goodbye. The registry TTL is the mechanism; this only makes it immediate.
|
|
5841
|
+
}
|
|
5842
|
+
process.exit(0);
|
|
5843
|
+
};
|
|
5844
|
+
|
|
5845
|
+
process.on('SIGTERM', () => { stop('signal'); });
|
|
5846
|
+
process.on('SIGINT', () => { stop('signal'); });
|
|
5847
|
+
|
|
5848
|
+
for (;;) {
|
|
5849
|
+
if (!processIsAlive(anchorPid)) {
|
|
5850
|
+
await stop('anchor_exited');
|
|
5851
|
+
return;
|
|
5852
|
+
}
|
|
5853
|
+
try {
|
|
5854
|
+
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
5855
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
5856
|
+
.post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
|
|
5857
|
+
if (response && response.registered === false) {
|
|
5858
|
+
// The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
|
|
5859
|
+
// than heartbeating into a void — the tab is still very much alive.
|
|
5860
|
+
appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
|
|
5861
|
+
await agentRegisterCommand(
|
|
5862
|
+
{ 'agent-id': agentId, 'base-url': context.baseUrl, json: 'true' },
|
|
5863
|
+
{ spawnHeartbeat: false }
|
|
5864
|
+
).catch((err) => {
|
|
5865
|
+
appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
|
|
5866
|
+
{ agentId, error: describeError(err) }, 'error');
|
|
5867
|
+
});
|
|
5868
|
+
}
|
|
5869
|
+
} catch (err) {
|
|
5870
|
+
appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
|
|
5871
|
+
}
|
|
5872
|
+
await new Promise((resolve) => setTimeout(resolve, AGENT_HEARTBEAT_INTERVAL_MS));
|
|
5873
|
+
}
|
|
5874
|
+
}
|
|
5875
|
+
|
|
5876
|
+
async function agentStatusCommand(flags) {
|
|
5877
|
+
const agentId = resolveAgentId(flags);
|
|
5878
|
+
const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
|
|
5879
|
+
const context = agentSessionFor(flags, local);
|
|
5880
|
+
|
|
5881
|
+
const update = { agentId };
|
|
5882
|
+
const positional = flags._ && flags._.length > 2 ? flags._.slice(2).join(' ') : null;
|
|
5883
|
+
const activity = flags.activity || positional;
|
|
5884
|
+
if (flags.state) {
|
|
5885
|
+
if (!AGENT_STATES.includes(String(flags.state))) {
|
|
5886
|
+
throw new Error('--state must be one of: ' + AGENT_STATES.join(', '));
|
|
5887
|
+
}
|
|
5888
|
+
update.state = String(flags.state);
|
|
5889
|
+
}
|
|
5890
|
+
if (activity != null) update.activity = String(activity);
|
|
5891
|
+
if (flags.ticket || flags['ticket-id']) update.ticketId = String(flags.ticket || flags['ticket-id']);
|
|
5892
|
+
if (flags.step) update.step = String(flags.step);
|
|
5893
|
+
if (flags.detail) update.detail = String(flags.detail);
|
|
5894
|
+
|
|
5895
|
+
// Reporting a ticket without a state is unambiguous: you are working it.
|
|
5896
|
+
if (update.ticketId && !update.state) update.state = 'working';
|
|
5897
|
+
|
|
5898
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 15000)
|
|
5899
|
+
.post('/cli/agentHeartbeat', update).then((r) => r.data);
|
|
5900
|
+
|
|
5901
|
+
if (response && response.registered === false) {
|
|
5902
|
+
console.log('This agent is no longer registered (its presence expired). Re-registering.');
|
|
5903
|
+
await agentRegisterCommand({ ...flags, 'agent-id': agentId });
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5907
|
+
if (flagEnabled(flags.json)) {
|
|
5908
|
+
console.log(JSON.stringify(response, null, 2));
|
|
5909
|
+
return;
|
|
5910
|
+
}
|
|
5911
|
+
const agent = (response && response.agent) || {};
|
|
5912
|
+
// `||` binds looser than `+`, so writing this as one expression prints only the state and silently
|
|
5913
|
+
// drops the ticket and activity — the two things the caller just set.
|
|
5914
|
+
const parts = [agent.state || 'idle'];
|
|
5915
|
+
if (agent.ticketId) parts.push('ticket ' + agent.ticketId);
|
|
5916
|
+
if (agent.activity) parts.push(agent.activity);
|
|
5917
|
+
console.log(parts.join(' '));
|
|
5918
|
+
}
|
|
5919
|
+
|
|
5920
|
+
/**
|
|
5921
|
+
* Ask for the work routed to this session.
|
|
5922
|
+
*
|
|
5923
|
+
* Polling, not listening: a terminal running an AI agent has no socket to push to, and the routing
|
|
5924
|
+
* decision it is asking about is a durable field on the ticket rather than a message that could be
|
|
5925
|
+
* missed. `--wait` turns a single question into a patient one.
|
|
5926
|
+
*/
|
|
5927
|
+
async function agentWorkCommand(flags) {
|
|
5928
|
+
const agentId = resolveAgentId(flags);
|
|
5929
|
+
const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
|
|
5930
|
+
const context = agentSessionFor(flags, local);
|
|
5931
|
+
const api = buildAxios(context.baseUrl, context.session.token, 20000);
|
|
5932
|
+
const waitSec = parsePositiveInt(flags.wait, AGENT_WORK_DEFAULT_WAIT_SEC);
|
|
5933
|
+
const deadline = Date.now() + (waitSec * 1000);
|
|
5934
|
+
|
|
5935
|
+
for (;;) {
|
|
5936
|
+
const response = await api.post('/cli/agentWork', {
|
|
5937
|
+
agentId,
|
|
5938
|
+
statuses: flags.statuses || 'all'
|
|
5939
|
+
}).then((r) => r.data);
|
|
5940
|
+
|
|
5941
|
+
if (response && response.registered === false) {
|
|
5942
|
+
console.log('This agent is no longer registered. Run: remits-cli agent register');
|
|
5943
|
+
process.exitCode = 1;
|
|
5944
|
+
return;
|
|
5945
|
+
}
|
|
5946
|
+
|
|
5947
|
+
const tickets = (response && response.tickets) || [];
|
|
5948
|
+
if (tickets.length || Date.now() >= deadline) {
|
|
5949
|
+
if (flagEnabled(flags.json)) {
|
|
5950
|
+
console.log(JSON.stringify(response, null, 2));
|
|
5951
|
+
return;
|
|
5952
|
+
}
|
|
5953
|
+
if (!tickets.length) {
|
|
5954
|
+
console.log('No tickets are routed to this agent.' +
|
|
5955
|
+
(waitSec ? ' (waited ' + waitSec + 's)' : ''));
|
|
5956
|
+
console.log('Nothing to do — the platform routes work here when an operator or a rule assigns it.');
|
|
5957
|
+
return;
|
|
5958
|
+
}
|
|
5959
|
+
console.log(tickets.length + ' ticket(s) routed to this agent:');
|
|
5960
|
+
for (const ticket of tickets) {
|
|
5961
|
+
console.log('');
|
|
5962
|
+
console.log(' ' + (ticket.id || ticket.ticketId) + ' [' + (ticket.priority || 'medium') + '/' +
|
|
5963
|
+
(ticket.status || 'open') + '/' + (ticket.type || 'question') + ']');
|
|
5964
|
+
console.log(' ' + (ticket.subject || '(no subject)'));
|
|
5965
|
+
console.log(' account ' + (ticket.accountName || '?') + ' (' + (ticket.accountId || '?') + ')' +
|
|
5966
|
+
(ticket.implementationAccountId
|
|
5967
|
+
? ' implementation ' + ticket.implementationAccountName + ' (' + ticket.implementationAccountId + ')'
|
|
5968
|
+
: ''));
|
|
5969
|
+
if (ticket.description) {
|
|
5970
|
+
console.log(' ' + String(ticket.description).split('\n')[0].slice(0, 200));
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
console.log('');
|
|
5974
|
+
console.log('Next: read it in full, then claim it.');
|
|
5975
|
+
console.log(' remits-cli tool --name mcp_support_ticket --input \'{"action":"read","ticketId":"' +
|
|
5976
|
+
(tickets[0].id || tickets[0].ticketId) + '"}\'');
|
|
5977
|
+
console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
|
|
5978
|
+
' --activity "reproducing the issue"');
|
|
5979
|
+
return;
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
await new Promise((resolve) => setTimeout(resolve, AGENT_WORK_POLL_MS));
|
|
5983
|
+
}
|
|
5984
|
+
}
|
|
5985
|
+
|
|
5986
|
+
async function agentListCommand(flags) {
|
|
5987
|
+
const cwd = process.cwd();
|
|
5988
|
+
const mine = listLocalAgents()[0] || null;
|
|
5989
|
+
const context = agentSessionFor(flags, mine);
|
|
5990
|
+
|
|
5991
|
+
// An explicit --account-id asks about one account. Otherwise, if this machine has a registered
|
|
5992
|
+
// agent, scope to the accounts it was accepted for — the peers it could actually share work with.
|
|
5993
|
+
// Falling back to "whichever session was most recent" answers a question nobody asked.
|
|
5994
|
+
const explicitAccountId = Number(flags && flags['account-id']);
|
|
5995
|
+
const scopedAccountIds = (!Number.isFinite(explicitAccountId) && mine && Array.isArray(mine.accountIds))
|
|
5996
|
+
? mine.accountIds
|
|
5997
|
+
: null;
|
|
5998
|
+
const accountId = Number.isFinite(explicitAccountId) && explicitAccountId > 0
|
|
5999
|
+
? explicitAccountId
|
|
6000
|
+
: (scopedAccountIds ? null : resolvePreferredAccountId(cwd, flags, context.session));
|
|
6001
|
+
|
|
6002
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
6003
|
+
.post('/cli/agents', {
|
|
6004
|
+
accountId,
|
|
6005
|
+
accountIds: scopedAccountIds,
|
|
6006
|
+
dataMode: context.dataMode,
|
|
6007
|
+
all: flagEnabled(flags.all)
|
|
6008
|
+
}).then((r) => r.data);
|
|
6009
|
+
|
|
6010
|
+
if (flagEnabled(flags.json)) {
|
|
6011
|
+
console.log(JSON.stringify(response, null, 2));
|
|
6012
|
+
return;
|
|
6013
|
+
}
|
|
6014
|
+
const scopeLabel = accountId ? 'account ' + accountId
|
|
6015
|
+
: (scopedAccountIds ? scopedAccountIds.length + ' account(s) this session covers' : 'your accounts');
|
|
6016
|
+
const agents = (response && response.agents) || [];
|
|
6017
|
+
if (!agents.length) {
|
|
6018
|
+
console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
|
|
6019
|
+
return;
|
|
6020
|
+
}
|
|
6021
|
+
console.log(agents.length + ' agent(s) available across ' + scopeLabel + ':');
|
|
6022
|
+
for (const agent of agents) {
|
|
6023
|
+
console.log('');
|
|
6024
|
+
console.log(' ' + agent.agentId);
|
|
6025
|
+
console.log(' ' + (agent.label || agent.userName || '(unlabelled)') + ' ' + (agent.userEmail || ''));
|
|
6026
|
+
console.log(' state ' + (agent.state || 'idle') +
|
|
6027
|
+
(agent.ticketId ? ' ticket ' + agent.ticketId : '') +
|
|
6028
|
+
' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
|
|
6029
|
+
if (agent.activity) console.log(' doing ' + agent.activity);
|
|
6030
|
+
console.log(' where ' + (agent.host || '?') + ':' + (agent.cwd || '?'));
|
|
6031
|
+
}
|
|
6032
|
+
}
|
|
6033
|
+
|
|
6034
|
+
async function agentReleaseCommand(flags) {
|
|
6035
|
+
const agentId = resolveAgentId(flags, { optional: true });
|
|
6036
|
+
if (!agentId) {
|
|
6037
|
+
console.log('No local agent is registered in this session.');
|
|
6038
|
+
return;
|
|
6039
|
+
}
|
|
6040
|
+
const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
|
|
6041
|
+
const context = agentSessionFor(flags, local);
|
|
6042
|
+
await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
6043
|
+
.post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
|
|
6044
|
+
forgetLocalAgent(agentId);
|
|
6045
|
+
console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
|
|
6046
|
+
}
|
|
6047
|
+
|
|
6048
|
+
async function agentCommand(flags, subcommand) {
|
|
6049
|
+
switch (subcommand) {
|
|
6050
|
+
case 'register': return agentRegisterCommand(flags);
|
|
6051
|
+
case 'status': return agentStatusCommand(flags);
|
|
6052
|
+
case 'work': return agentWorkCommand(flags);
|
|
6053
|
+
case 'list': case 'ls': return agentListCommand(flags);
|
|
6054
|
+
case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
|
|
6055
|
+
case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
|
|
6056
|
+
default:
|
|
6057
|
+
printAgentHelp();
|
|
6058
|
+
return;
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
function printAgentHelp() {
|
|
6063
|
+
console.log('Usage: remits-cli agent <subcommand>');
|
|
6064
|
+
console.log('');
|
|
6065
|
+
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
|
|
6066
|
+
console.log(' Make THIS terminal session available to work support tickets. Runs from ANY');
|
|
6067
|
+
console.log(' directory — it registers for every account repo indexed on this machine that your');
|
|
6068
|
+
console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
|
|
6069
|
+
console.log(' production platform and the prod lane, because a support agent works real tickets.');
|
|
6070
|
+
console.log(' Starts a heartbeat that ends when this session ends, so a closed tab stops');
|
|
6071
|
+
console.log(' receiving work on its own.');
|
|
6072
|
+
console.log('');
|
|
6073
|
+
console.log(' work [--wait SECONDS] [--json]');
|
|
6074
|
+
console.log(' Ask for the tickets routed to this session. With --wait it keeps asking until');
|
|
6075
|
+
console.log(' something arrives or the wait runs out.');
|
|
6076
|
+
console.log('');
|
|
6077
|
+
console.log(' status [--state idle|working|paused] [--ticket ID] [--activity "..."] [--step "..."]');
|
|
6078
|
+
console.log(' Report what this session is doing, so operators can see it live.');
|
|
6079
|
+
console.log('');
|
|
6080
|
+
console.log(' list [--account-id ID] [--json] Who else is available for an account.');
|
|
6081
|
+
console.log(' release Deregister this session immediately.');
|
|
6082
|
+
console.log('');
|
|
6083
|
+
console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
|
|
6084
|
+
console.log(' and identity this session registered with.');
|
|
6085
|
+
}
|
|
6086
|
+
|
|
6003
6087
|
async function listenCommand(flags) {
|
|
6004
6088
|
if (isListenerRunning()) {
|
|
6005
6089
|
const state = readServiceState();
|
|
@@ -6079,43 +6163,15 @@ async function listenCommand(flags) {
|
|
|
6079
6163
|
return;
|
|
6080
6164
|
}
|
|
6081
6165
|
|
|
6082
|
-
// Foreground mode — this is the actual long-running
|
|
6083
|
-
// Clean slate: kill any leftover tmux session and pane state from a previous run
|
|
6084
|
-
killTmuxSession();
|
|
6085
|
-
paneMap = {};
|
|
6086
|
-
savePaneMap();
|
|
6087
|
-
|
|
6166
|
+
// Foreground mode — this is the actual long-running service
|
|
6088
6167
|
writeListenerPid();
|
|
6089
6168
|
runtimeState.service.startedAt = new Date().toISOString();
|
|
6090
6169
|
runtimeState.service.pid = process.pid;
|
|
6091
6170
|
recordDiscoverySummary(discoverAccountRepos());
|
|
6092
6171
|
const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
|
|
6093
6172
|
await startDashboardServer(dashboardPort);
|
|
6094
|
-
await bestEffortRegisterCliListeners({ reason: 'listener.start' });
|
|
6095
|
-
let lastListenerRegistrationAt = Date.now();
|
|
6096
|
-
|
|
6097
|
-
// Create the tmux session immediately so the user can attach right away
|
|
6098
|
-
try {
|
|
6099
|
-
const defaultDir = os.homedir();
|
|
6100
|
-
execSync(
|
|
6101
|
-
'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(defaultDir),
|
|
6102
|
-
{ stdio: 'ignore' }
|
|
6103
|
-
);
|
|
6104
|
-
configureTmuxSession();
|
|
6105
|
-
appendGlobalActivityLog('tmux', 'session.created', {
|
|
6106
|
-
sessionName: TMUX_SESSION_NAME,
|
|
6107
|
-
accountDir: defaultDir,
|
|
6108
|
-
source: 'listener.start'
|
|
6109
|
-
});
|
|
6110
|
-
} catch (err) {
|
|
6111
|
-
console.error('[listener] Failed to create tmux session:', err.message);
|
|
6112
|
-
appendGlobalActivityLog('tmux', 'session.create_failed', {
|
|
6113
|
-
sessionName: TMUX_SESSION_NAME,
|
|
6114
|
-
error: err.message
|
|
6115
|
-
}, 'error');
|
|
6116
|
-
}
|
|
6117
6173
|
|
|
6118
|
-
// Clean up on exit
|
|
6174
|
+
// Clean up on exit
|
|
6119
6175
|
let refreshInterval = null;
|
|
6120
6176
|
const cleanup = () => {
|
|
6121
6177
|
appendGlobalActivityLog('listener', 'cleanup', { pid: process.pid });
|
|
@@ -6136,9 +6192,6 @@ async function listenCommand(flags) {
|
|
|
6136
6192
|
} catch (_) {}
|
|
6137
6193
|
dashboardServer = null;
|
|
6138
6194
|
}
|
|
6139
|
-
killTmuxSession();
|
|
6140
|
-
paneMap = {};
|
|
6141
|
-
savePaneMap();
|
|
6142
6195
|
clearListenerPid();
|
|
6143
6196
|
clearServiceState();
|
|
6144
6197
|
process.exit(0);
|
|
@@ -6151,7 +6204,7 @@ async function listenCommand(flags) {
|
|
|
6151
6204
|
appendGlobalActivityLog('listener', 'foreground.started', {
|
|
6152
6205
|
pid: process.pid,
|
|
6153
6206
|
accountCount: entries.length,
|
|
6154
|
-
logFile:
|
|
6207
|
+
logFile: ACTIVITY_LOG_FILE,
|
|
6155
6208
|
dashboardUrl: runtimeState.service.dashboardUrl
|
|
6156
6209
|
});
|
|
6157
6210
|
|
|
@@ -6166,8 +6219,6 @@ async function listenCommand(flags) {
|
|
|
6166
6219
|
}
|
|
6167
6220
|
maintenanceInFlight = true;
|
|
6168
6221
|
try {
|
|
6169
|
-
ensureTmuxSessionHealthy(os.homedir());
|
|
6170
|
-
|
|
6171
6222
|
const currentEntries = listenerSessionEntriesFromStore(readAllSessions());
|
|
6172
6223
|
const currentSignature = listenerSessionSignature(currentEntries);
|
|
6173
6224
|
if (currentSignature !== activeSessionSignature) {
|
|
@@ -6180,11 +6231,6 @@ async function listenCommand(flags) {
|
|
|
6180
6231
|
activeSessionSignature = currentSignature;
|
|
6181
6232
|
}
|
|
6182
6233
|
|
|
6183
|
-
if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
|
|
6184
|
-
await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
|
|
6185
|
-
lastListenerRegistrationAt = Date.now();
|
|
6186
|
-
}
|
|
6187
|
-
|
|
6188
6234
|
await runManagedWebsocketWatchdog();
|
|
6189
6235
|
updateRuntimeServiceState();
|
|
6190
6236
|
} catch (err) {
|
|
@@ -6218,17 +6264,6 @@ async function listenStopCommand() {
|
|
|
6218
6264
|
appendGlobalActivityLog('listener', 'stop.no_process');
|
|
6219
6265
|
}
|
|
6220
6266
|
|
|
6221
|
-
// Kill the tmux session and clear pane tracking
|
|
6222
|
-
if (tmuxSessionExists()) {
|
|
6223
|
-
killTmuxSession();
|
|
6224
|
-
console.log('Killed tmux session:', TMUX_SESSION_NAME);
|
|
6225
|
-
}
|
|
6226
|
-
if (fs.existsSync(DISPATCH_STATE_FILE)) {
|
|
6227
|
-
atomicWriteFile(DISPATCH_STATE_FILE, '{}');
|
|
6228
|
-
appendGlobalActivityLog('tmux', 'dispatch_state.cleared', {
|
|
6229
|
-
file: DISPATCH_STATE_FILE
|
|
6230
|
-
});
|
|
6231
|
-
}
|
|
6232
6267
|
clearServiceState();
|
|
6233
6268
|
}
|
|
6234
6269
|
|
|
@@ -6571,6 +6606,14 @@ async function main() {
|
|
|
6571
6606
|
const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
|
|
6572
6607
|
const wantsJson = flagEnabled(args.json);
|
|
6573
6608
|
|
|
6609
|
+
// The heartbeat sidecar runs for the whole life of a terminal session. It must not auto-update
|
|
6610
|
+
// (re-exec'ing it would drop the anchor it is watching) and it has no use for repo discovery or the
|
|
6611
|
+
// background service, so it short-circuits everything below.
|
|
6612
|
+
if (command === 'agent' && subcommand === 'heartbeat-daemon') {
|
|
6613
|
+
await agentHeartbeatDaemonCommand(args);
|
|
6614
|
+
return;
|
|
6615
|
+
}
|
|
6616
|
+
|
|
6574
6617
|
if (shouldAutoUpdate(command, args)) {
|
|
6575
6618
|
const requireSuccessfulAutoUpdate = command === 'start';
|
|
6576
6619
|
if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
|
|
@@ -6578,13 +6621,9 @@ async function main() {
|
|
|
6578
6621
|
|
|
6579
6622
|
// Best-effort: update account-repo index whenever running from inside a remits repo
|
|
6580
6623
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
6581
|
-
if (!wantsJson) {
|
|
6582
|
-
try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
|
|
6583
|
-
}
|
|
6584
|
-
|
|
6585
6624
|
// Auto-start the background service if not already running.
|
|
6586
6625
|
// Skip for lifecycle subcommands and help.
|
|
6587
|
-
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami';
|
|
6626
|
+
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
|
|
6588
6627
|
const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
|
|
6589
6628
|
if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
|
|
6590
6629
|
try {
|
|
@@ -6611,7 +6650,12 @@ async function main() {
|
|
|
6611
6650
|
console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
|
|
6612
6651
|
console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
|
|
6613
6652
|
console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
|
|
6614
|
-
console.log(' remits-cli
|
|
6653
|
+
console.log(' remits-cli agent register # become a support agent (any directory, prod by default)');
|
|
6654
|
+
console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
|
|
6655
|
+
console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
|
|
6656
|
+
console.log(' remits-cli agent list [--account-id ID] # who else is available');
|
|
6657
|
+
console.log(' remits-cli agent release # stop receiving tickets now');
|
|
6658
|
+
console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
|
|
6615
6659
|
console.log(' remits-cli stop');
|
|
6616
6660
|
console.log(' remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
|
|
6617
6661
|
console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
|
|
@@ -6760,6 +6804,15 @@ async function main() {
|
|
|
6760
6804
|
return;
|
|
6761
6805
|
}
|
|
6762
6806
|
|
|
6807
|
+
if (command === 'agent') {
|
|
6808
|
+
if (wantsHelp) {
|
|
6809
|
+
printAgentHelp();
|
|
6810
|
+
process.exit(0);
|
|
6811
|
+
}
|
|
6812
|
+
await agentCommand(args, subcommand);
|
|
6813
|
+
return;
|
|
6814
|
+
}
|
|
6815
|
+
|
|
6763
6816
|
if (command === 'install') {
|
|
6764
6817
|
await installSkillsCommand(args);
|
|
6765
6818
|
return;
|
|
@@ -6789,6 +6842,9 @@ async function main() {
|
|
|
6789
6842
|
}
|
|
6790
6843
|
|
|
6791
6844
|
main().catch((err) => {
|
|
6845
|
+
if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
|
|
6846
|
+
console.error(err.stack);
|
|
6847
|
+
}
|
|
6792
6848
|
console.error('remits-cli error:', describeError(err) + sandboxNetworkHint(err));
|
|
6793
6849
|
process.exit(1);
|
|
6794
6850
|
});
|