@remits/remits-cli 0.1.69

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 ADDED
@@ -0,0 +1,4379 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const http = require('http');
7
+ const crypto = require('crypto');
8
+ const { execFileSync, execSync, spawnSync } = require('child_process');
9
+ const axios = require('axios');
10
+ const openModule = require('open');
11
+ const { Client } = require('@stomp/stompjs');
12
+ const WebSocket = require('ws');
13
+ const YAML = require('yaml');
14
+
15
+ Object.assign(global, { WebSocket });
16
+ const openBrowser = (openModule && typeof openModule === 'function')
17
+ ? openModule
18
+ : (openModule && typeof openModule.default === 'function' ? openModule.default : null);
19
+
20
+ //const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'http://localhost:8080';
21
+ const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'https://remits-529558023549.us-east5.run.app';
22
+ const SESSION_DIR = path.join(os.homedir(), '.remits-cli');
23
+ const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
24
+ const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
25
+ const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
26
+ const TMUX_ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'tmux-activity.log');
27
+ const SKILL_SOURCE_FILE = path.join(__dirname, 'skills', 'remits-cli', 'SKILL.md');
28
+ const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
29
+ const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
30
+ const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
31
+ const WEBSOCKET_STATE_FILE = path.join(SESSION_DIR, 'websocket-state.json');
32
+ const AUTO_UPDATE_LOCK_DIR = path.join(SESSION_DIR, 'auto-update.lock');
33
+ const AUTO_UPDATE_DISABLED_VALUES = new Set(['0', 'false', 'no', 'off']);
34
+ const REMITS_CLI_PACKAGE_NAME = '@remits/remits-cli';
35
+ const DEFAULT_DATA_MODE = 'test';
36
+ const DEFAULT_AGENT = 'claude';
37
+ const SUPPORTED_AGENTS = ['claude', 'codex', 'gemini'];
38
+ const MAX_TRACKED_MESSAGE_IDS = 1000;
39
+ const MAX_RECENT_WEBSOCKET_MESSAGES = 40;
40
+ const DASHBOARD_HOST = '127.0.0.1';
41
+ const DEFAULT_DASHBOARD_PORT = Number(process.env.REMITS_CLI_DASHBOARD_PORT || 8787);
42
+ const DEFAULT_SCAN_MAX_DEPTH = Number(process.env.REMITS_CLI_SCAN_MAX_DEPTH || 8);
43
+ const SERVICE_MAINTENANCE_INTERVAL_MS = Number(process.env.REMITS_CLI_SERVICE_MAINTENANCE_INTERVAL_MS || 15000);
44
+ const WEBSOCKET_RECONNECT_DELAY_MS = Number(process.env.REMITS_CLI_WEBSOCKET_RECONNECT_DELAY_MS || 5000);
45
+ const WEBSOCKET_HEARTBEAT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_HEARTBEAT_MS || 10000);
46
+ const WEBSOCKET_CONNECTION_TIMEOUT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_CONNECTION_TIMEOUT_MS || 15000);
47
+ const WEBSOCKET_WATCHDOG_GRACE_MS = Number(process.env.REMITS_CLI_WEBSOCKET_WATCHDOG_GRACE_MS || 30000);
48
+ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
49
+ '.git',
50
+ '.gradle',
51
+ '.idea',
52
+ '.next',
53
+ '.npm',
54
+ '.pnpm-store',
55
+ '.remits-cli',
56
+ '.terraform',
57
+ '.trash',
58
+ '.venv',
59
+ '.yarn',
60
+ 'Applications',
61
+ 'Library',
62
+ 'Movies',
63
+ 'Music',
64
+ 'Pictures',
65
+ 'System',
66
+ 'Trash',
67
+ 'Volumes',
68
+ 'build',
69
+ 'dist',
70
+ 'node_modules',
71
+ 'target',
72
+ 'tmp'
73
+ ]);
74
+
75
+ function parseArgs(argv) {
76
+ const out = { _: [] };
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const arg = argv[i];
79
+ if (!arg.startsWith('--')) {
80
+ out._.push(arg);
81
+ continue;
82
+ }
83
+ const key = arg.slice(2);
84
+ const next = argv[i + 1];
85
+ if (!next || next.startsWith('--')) {
86
+ out[key] = true;
87
+ } else {
88
+ out[key] = next;
89
+ i += 1;
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function ensureSessionDir() {
96
+ if (!fs.existsSync(SESSION_DIR)) {
97
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
98
+ }
99
+ }
100
+
101
+ function atomicWriteFile(file, content) {
102
+ ensureDir(path.dirname(file));
103
+ const tempFile = path.join(
104
+ path.dirname(file),
105
+ '.' + path.basename(file) + '.tmp-' + process.pid + '-' + crypto.randomUUID().slice(0, 8)
106
+ );
107
+ fs.writeFileSync(tempFile, content);
108
+ fs.renameSync(tempFile, file);
109
+ }
110
+
111
+ function normalizeBaseUrl(value) {
112
+ return String(value || '').trim().replace(/\/+$/, '');
113
+ }
114
+
115
+ function hasExplicitDataModeFlag(flags) {
116
+ return Boolean(
117
+ flags &&
118
+ (
119
+ Object.prototype.hasOwnProperty.call(flags, 'data-mode') ||
120
+ Object.prototype.hasOwnProperty.call(flags, 'dataMode')
121
+ )
122
+ );
123
+ }
124
+
125
+ function canonicalSessionKey(accountId, baseUrl, dataMode) {
126
+ const normalizedAccountId = Number(accountId);
127
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
128
+ const normalizedDataMode = normalizeDataMode(dataMode);
129
+ return String(normalizedAccountId) + '::' + normalizedDataMode + '::' + normalizedBaseUrl;
130
+ }
131
+
132
+ function listSessionsFromStore(store) {
133
+ return Object.values(store || {})
134
+ .filter((session) => session && session.accountId && session.token)
135
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
136
+ }
137
+
138
+ function normalizeSessionsStore(rawSessions) {
139
+ const normalized = {};
140
+ for (const [rawKey, rawValue] of Object.entries(rawSessions || {})) {
141
+ if (!rawValue || typeof rawValue !== 'object') {
142
+ continue;
143
+ }
144
+ const accountId = Number(rawValue.accountId || rawKey);
145
+ if (!Number.isFinite(accountId) || accountId <= 0) {
146
+ continue;
147
+ }
148
+ const session = {
149
+ ...rawValue,
150
+ accountId,
151
+ baseUrl: normalizeBaseUrl(rawValue.baseUrl || DEFAULT_BASE_URL),
152
+ dataMode: normalizeDataMode(rawValue.dataMode),
153
+ updatedAt: rawValue.updatedAt || new Date().toISOString()
154
+ };
155
+ normalized[canonicalSessionKey(accountId, session.baseUrl, session.dataMode)] = session;
156
+ }
157
+ return normalized;
158
+ }
159
+
160
+ function readAllSessions() {
161
+ if (!fs.existsSync(SESSIONS_FILE)) {
162
+ return {};
163
+ }
164
+ try {
165
+ return normalizeSessionsStore(JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')));
166
+ } catch (_) {
167
+ return {};
168
+ }
169
+ }
170
+
171
+ function writeAllSessions(sessions) {
172
+ ensureSessionDir();
173
+ atomicWriteFile(SESSIONS_FILE, JSON.stringify(normalizeSessionsStore(sessions), null, 2));
174
+ }
175
+
176
+ function readSession(match = {}, options = {}) {
177
+ const criteria = (typeof match === 'object' && match !== null) ? match : { accountId: match };
178
+ const normalizedAccountId = criteria.accountId != null ? Number(criteria.accountId) : null;
179
+ const normalizedBaseUrl = criteria.baseUrl ? normalizeBaseUrl(criteria.baseUrl) : null;
180
+ const normalizedDataMode = criteria.dataMode ? normalizeDataMode(criteria.dataMode) : null;
181
+ const strictBaseUrl = Boolean(options.strictBaseUrl && normalizedBaseUrl);
182
+ const strictDataMode = Boolean(options.strictDataMode && normalizedDataMode);
183
+ const entries = listSessionsFromStore(readAllSessions());
184
+
185
+ let candidates = entries.filter((session) => {
186
+ if (normalizedAccountId && Number(session.accountId) !== normalizedAccountId) {
187
+ return false;
188
+ }
189
+ if (strictBaseUrl && normalizeBaseUrl(session.baseUrl) !== normalizedBaseUrl) {
190
+ return false;
191
+ }
192
+ if (strictDataMode && normalizeDataMode(session.dataMode) !== normalizedDataMode) {
193
+ return false;
194
+ }
195
+ return true;
196
+ });
197
+
198
+ if (!candidates.length) {
199
+ return null;
200
+ }
201
+
202
+ candidates.sort((a, b) => {
203
+ const score = (session) => {
204
+ let value = 0;
205
+ if (normalizedAccountId && Number(session.accountId) === normalizedAccountId) value += 8;
206
+ if (normalizedBaseUrl && normalizeBaseUrl(session.baseUrl) === normalizedBaseUrl) value += 4;
207
+ if (normalizedDataMode && normalizeDataMode(session.dataMode) === normalizedDataMode) value += 2;
208
+ return value;
209
+ };
210
+ const scoreDiff = score(b) - score(a);
211
+ if (scoreDiff !== 0) {
212
+ return scoreDiff;
213
+ }
214
+ return String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''));
215
+ });
216
+
217
+ return candidates[0] || null;
218
+ }
219
+
220
+ function writeSession(accountId, data, options = {}) {
221
+ const sessions = readAllSessions();
222
+ const session = {
223
+ ...data,
224
+ accountId: Number(accountId),
225
+ baseUrl: normalizeBaseUrl((options.baseUrl || data.baseUrl || DEFAULT_BASE_URL)),
226
+ dataMode: normalizeDataMode(options.dataMode || data.dataMode),
227
+ updatedAt: data.updatedAt || new Date().toISOString()
228
+ };
229
+ sessions[canonicalSessionKey(session.accountId, session.baseUrl, session.dataMode)] = session;
230
+ writeAllSessions(sessions);
231
+ }
232
+
233
+ function readConfig() {
234
+ if (!fs.existsSync(CONFIG_FILE)) {
235
+ return {};
236
+ }
237
+ try {
238
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
239
+ } catch (_) {
240
+ return {};
241
+ }
242
+ }
243
+
244
+ function writeConfig(data) {
245
+ ensureSessionDir();
246
+ atomicWriteFile(CONFIG_FILE, JSON.stringify(data, null, 2));
247
+ }
248
+
249
+ function getPreferredAgent() {
250
+ const config = readConfig();
251
+ return config.agent || DEFAULT_AGENT;
252
+ }
253
+
254
+ function normalizeDataMode(value) {
255
+ const mode = String(value || DEFAULT_DATA_MODE).trim().toLowerCase();
256
+ return mode === 'prod' ? 'prod' : 'test';
257
+ }
258
+
259
+ function resolveDataMode(flags, session) {
260
+ if (flags && Object.prototype.hasOwnProperty.call(flags, 'data-mode')) {
261
+ return normalizeDataMode(flags['data-mode']);
262
+ }
263
+ if (flags && Object.prototype.hasOwnProperty.call(flags, 'dataMode')) {
264
+ return normalizeDataMode(flags.dataMode);
265
+ }
266
+ if (session && session.dataMode) {
267
+ return normalizeDataMode(session.dataMode);
268
+ }
269
+ return DEFAULT_DATA_MODE;
270
+ }
271
+
272
+ function ensureDir(dirPath) {
273
+ if (!fs.existsSync(dirPath)) {
274
+ fs.mkdirSync(dirPath, { recursive: true });
275
+ }
276
+ }
277
+
278
+ function localStatePaths(cwd) {
279
+ const base = path.join(cwd, '.remits-cli');
280
+ return {
281
+ base,
282
+ toolsDir: path.join(base, 'tools'),
283
+ sessionsDir: path.join(base, 'sessions'),
284
+ toolResponsesDir: path.join(base, 'tool-responses'),
285
+ currentSessionFile: path.join(base, 'current-session.txt')
286
+ };
287
+ }
288
+
289
+ function ensureLocalState(cwd) {
290
+ const paths = localStatePaths(cwd);
291
+ ensureDir(paths.base);
292
+ ensureDir(paths.toolsDir);
293
+ ensureDir(paths.sessionsDir);
294
+ ensureDir(paths.toolResponsesDir);
295
+ return paths;
296
+ }
297
+
298
+ function currentLocalSessionName(cwd) {
299
+ const paths = ensureLocalState(cwd);
300
+ if (fs.existsSync(paths.currentSessionFile)) {
301
+ const existing = fs.readFileSync(paths.currentSessionFile, 'utf8').trim();
302
+ if (existing) {
303
+ return existing;
304
+ }
305
+ }
306
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
307
+ const sessionName = 'session-' + timestamp + '-' + crypto.randomUUID().slice(0, 8);
308
+ atomicWriteFile(paths.currentSessionFile, sessionName + '\n');
309
+ return sessionName;
310
+ }
311
+
312
+ function sessionJsonlFile(cwd) {
313
+ const paths = ensureLocalState(cwd);
314
+ return path.join(paths.sessionsDir, currentLocalSessionName(cwd) + '.jsonl');
315
+ }
316
+
317
+ const CONTENT_FIELDS = new Set(['source', 'html', 'javascript', 'css', 'schema', 'inputSchema', 'previewData']);
318
+
319
+ function sanitizeForLog(value) {
320
+ if (value === null || value === undefined) {
321
+ return value;
322
+ }
323
+ if (Array.isArray(value)) {
324
+ return value.map(sanitizeForLog);
325
+ }
326
+ if (typeof value !== 'object') {
327
+ return value;
328
+ }
329
+ const out = {};
330
+ for (const [k, v] of Object.entries(value)) {
331
+ if (k.toLowerCase() === 'token') {
332
+ out[k] = '[redacted]';
333
+ } else if (CONTENT_FIELDS.has(k) && typeof v === 'string') {
334
+ out[k] = '[' + v.length + ' chars]';
335
+ } else {
336
+ out[k] = sanitizeForLog(v);
337
+ }
338
+ }
339
+ return out;
340
+ }
341
+
342
+ function appendSessionLog(cwd, entry) {
343
+ const file = sessionJsonlFile(cwd);
344
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
345
+ return file;
346
+ }
347
+
348
+ function summarizeDispatchMessage(message) {
349
+ const safeMessage = message && typeof message === 'object' ? message : {};
350
+ const promptText = safeMessage.prompt || safeMessage.message || '';
351
+ return sanitizeForLog({
352
+ accountId: safeMessage.accountId || null,
353
+ implementationAccountId: safeMessage.implementationAccountId || null,
354
+ ticketId: safeMessage.ticketId || null,
355
+ legacyTaskId: safeMessage.taskId || null,
356
+ type: safeMessage.type || null,
357
+ promptLength: typeof promptText === 'string' ? promptText.length : 0,
358
+ keys: Object.keys(safeMessage).sort()
359
+ });
360
+ }
361
+
362
+ function appendGlobalActivityLog(scope, event, meta = {}, level = 'info') {
363
+ ensureSessionDir();
364
+ const safeMeta = sanitizeForLog(meta);
365
+ const payload = Object.keys(safeMeta || {}).length ? ' ' + JSON.stringify(safeMeta) : '';
366
+ const line = new Date().toISOString() + ' [' + level + '] [' + scope + '] ' + event + payload + '\n';
367
+ fs.appendFileSync(TMUX_ACTIVITY_LOG_FILE, line);
368
+ return TMUX_ACTIVITY_LOG_FILE;
369
+ }
370
+
371
+ function writeToolResponseFile(cwd, callId, responsePayload) {
372
+ const paths = ensureLocalState(cwd);
373
+ const file = path.join(paths.toolResponsesDir, callId + '.json');
374
+ atomicWriteFile(file, JSON.stringify(responsePayload, null, 2));
375
+ return file;
376
+ }
377
+
378
+ function normalizeName(name) {
379
+ return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim();
380
+ }
381
+
382
+ function stableStringify(obj) {
383
+ if (obj === null || typeof obj !== 'object') {
384
+ return JSON.stringify(obj);
385
+ }
386
+ if (Array.isArray(obj)) {
387
+ return '[' + obj.map(stableStringify).join(',') + ']';
388
+ }
389
+ const keys = Object.keys(obj).sort();
390
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
391
+ }
392
+
393
+ function sha256(value) {
394
+ return crypto.createHash('sha256').update(value).digest('hex');
395
+ }
396
+
397
+ function currentBranch(cwd) {
398
+ try {
399
+ return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
400
+ } catch (err) {
401
+ return 'main';
402
+ }
403
+ }
404
+
405
+ function shellQuote(value) {
406
+ return '\'' + String(value).replace(/'/g, '\'\"\'\"\'' ) + '\'';
407
+ }
408
+
409
+ function runGit(cwd, command) {
410
+ try {
411
+ return execSync(command, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
412
+ } catch (err) {
413
+ const stderr = err && err.stderr ? err.stderr.toString().trim() : '';
414
+ const stdout = err && err.stdout ? err.stdout.toString().trim() : '';
415
+ const details = stderr || stdout || err.message;
416
+ throw new Error('Git command failed: ' + command + (details ? ' :: ' + details : ''));
417
+ }
418
+ }
419
+
420
+ function currentHeadSha(cwd, ref = 'HEAD') {
421
+ return runGit(cwd, 'git rev-parse ' + shellQuote(ref));
422
+ }
423
+
424
+ function loadAccountId(cwd) {
425
+ const file = path.join(cwd, 'account-info.json');
426
+ if (!fs.existsSync(file)) {
427
+ throw new Error('account-info.json not found in current directory');
428
+ }
429
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
430
+ return Number(data.id || data.accountId || (data.account && data.account.id));
431
+ }
432
+
433
+ function loadAccountInfo(cwd) {
434
+ const file = path.join(cwd, 'account-info.json');
435
+ if (!fs.existsSync(file)) {
436
+ return null;
437
+ }
438
+ try {
439
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
440
+ } catch (_) {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ function readAccountRepoIndex() {
446
+ if (!fs.existsSync(ACCOUNT_REPO_INDEX_FILE)) {
447
+ return {};
448
+ }
449
+ try {
450
+ return JSON.parse(fs.readFileSync(ACCOUNT_REPO_INDEX_FILE, 'utf8'));
451
+ } catch (_) {
452
+ return {};
453
+ }
454
+ }
455
+
456
+ function writeAccountRepoIndex(index) {
457
+ ensureSessionDir();
458
+ atomicWriteFile(ACCOUNT_REPO_INDEX_FILE, JSON.stringify(index, null, 2));
459
+ }
460
+
461
+ function buildAccountRepoEntryFromInfo(info, directory, source = 'discovered') {
462
+ if (!info || typeof info !== 'object') {
463
+ return null;
464
+ }
465
+ const accountId = Number(info.id || info.accountId || (info.account && info.account.id));
466
+ if (!Number.isFinite(accountId) || accountId <= 0) {
467
+ return null;
468
+ }
469
+ return {
470
+ accountId,
471
+ name: info.name || info.accountName || (info.account && info.account.name) || '',
472
+ type: info.type || info.accountType || (info.account && info.account.type) || null,
473
+ platformAccountId: info.platformAccountId || (info.platform && info.platform.id) || null,
474
+ directory: path.resolve(directory),
475
+ accountInfoPath: path.join(path.resolve(directory), 'account-info.json'),
476
+ source,
477
+ updatedAt: new Date().toISOString()
478
+ };
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Guide distribution
483
+ //
484
+ // Front-stage guides are NOT bundled with the npm package. They are downloaded
485
+ // from the platform's authenticated `/cli/guides` endpoint and written into the
486
+ // local account repo, so only users who can authenticate to the Remits platform
487
+ // ever receive them. See ensureGuidesSynced() / downloadAndSyncGuides().
488
+ // ---------------------------------------------------------------------------
489
+
490
+ function removeLegacyGuideCopies(repoGuidesDir, fileNames, summary, repoRoot) {
491
+ for (const fileName of fileNames) {
492
+ const legacyPath = path.join(repoGuidesDir, fileName);
493
+ if (!fs.existsSync(legacyPath)) {
494
+ continue;
495
+ }
496
+ try {
497
+ fs.unlinkSync(legacyPath);
498
+ if (summary && Array.isArray(summary.removed)) {
499
+ summary.removed.push(path.relative(repoRoot, legacyPath));
500
+ }
501
+ } catch (_) {
502
+ // Best-effort cleanup; a later sync still refreshes the root copy.
503
+ }
504
+ }
505
+ }
506
+
507
+ function writeFileIfChanged(filePath, content) {
508
+ if (fs.existsSync(filePath)) {
509
+ try {
510
+ if (fs.readFileSync(filePath, 'utf8') === content) {
511
+ return false;
512
+ }
513
+ } catch (_) {
514
+ // Fall through and rewrite unreadable files.
515
+ }
516
+ }
517
+ atomicWriteFile(filePath, content);
518
+ return true;
519
+ }
520
+
521
+ function writeGuideGroup(group, targetDir, repoRoot, summary) {
522
+ if (!group || typeof group !== 'object') {
523
+ return;
524
+ }
525
+ const names = Object.keys(group).filter((name) => typeof group[name] === 'string').sort();
526
+ if (!names.length) {
527
+ return;
528
+ }
529
+ ensureDir(targetDir);
530
+ for (const name of names) {
531
+ const targetPath = path.join(targetDir, name);
532
+ if (writeFileIfChanged(targetPath, group[name])) {
533
+ summary.updated.push(path.relative(repoRoot, targetPath));
534
+ }
535
+ }
536
+ }
537
+
538
+ // Write a guide payload downloaded from `/cli/guides` into an account repo.
539
+ // Mirrors the layout the platform exports: the instruction template fans out to
540
+ // the agent guidance files, root guides land at the repo root, and component /
541
+ // feature guides land under guides/ and guides/features/.
542
+ function writeGuidePayloadIntoRepo(repoDir, payload) {
543
+ const resolvedRepoDir = path.resolve(repoDir);
544
+ const repoGuidesDir = path.join(resolvedRepoDir, 'guides');
545
+ const summary = {
546
+ updated: [],
547
+ removed: [],
548
+ missingSources: []
549
+ };
550
+
551
+ const instruction = payload && payload.instruction;
552
+ if (typeof instruction === 'string' && instruction.length) {
553
+ for (const fileName of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
554
+ const targetPath = path.join(resolvedRepoDir, fileName);
555
+ if (writeFileIfChanged(targetPath, instruction)) {
556
+ summary.updated.push(path.relative(resolvedRepoDir, targetPath));
557
+ }
558
+ }
559
+ }
560
+
561
+ ensureDir(repoGuidesDir);
562
+ // Older CLI versions copied these into guides/; they now live at the repo root.
563
+ removeLegacyGuideCopies(repoGuidesDir, [
564
+ 'platform-overview.md',
565
+ 'development-guide.md'
566
+ ], summary, resolvedRepoDir);
567
+
568
+ const rootFiles = (payload && payload.root) || {};
569
+ for (const name of Object.keys(rootFiles).sort()) {
570
+ if (typeof rootFiles[name] !== 'string') {
571
+ continue;
572
+ }
573
+ const targetPath = path.join(resolvedRepoDir, name);
574
+ if (writeFileIfChanged(targetPath, rootFiles[name])) {
575
+ summary.updated.push(path.relative(resolvedRepoDir, targetPath));
576
+ }
577
+ }
578
+
579
+ writeGuideGroup(payload && payload.components, repoGuidesDir, resolvedRepoDir, summary);
580
+ writeGuideGroup(payload && payload.features, path.join(repoGuidesDir, 'features'), resolvedRepoDir, summary);
581
+
582
+ return summary;
583
+ }
584
+
585
+ // Cache the downloaded payload for the lifetime of the process so a single
586
+ // invocation that touches multiple repos only fetches guides once.
587
+ let GUIDES_PAYLOAD_CACHE = null;
588
+
589
+ async function fetchGuidesPayload(cwd, ctx) {
590
+ const baseUrl = normalizeBaseUrl((ctx && ctx.baseUrl) || DEFAULT_BASE_URL);
591
+ const token = ctx && ctx.session && ctx.session.token;
592
+ if (!token) {
593
+ return null;
594
+ }
595
+ const cacheKey = baseUrl + '|' + token;
596
+ if (GUIDES_PAYLOAD_CACHE && GUIDES_PAYLOAD_CACHE.key === cacheKey) {
597
+ return GUIDES_PAYLOAD_CACHE.payload;
598
+ }
599
+ const api = buildAxios(baseUrl, token);
600
+ let data = null;
601
+ try {
602
+ data = await loggedPost(api, cwd, '/cli/guides', {
603
+ token,
604
+ accountId: (ctx && ctx.accountId) || undefined
605
+ }).then((r) => r.data);
606
+ } catch (_) {
607
+ // Best-effort: never fail a command because guides could not be fetched.
608
+ return null;
609
+ }
610
+ if (!data || !data.success) {
611
+ return null;
612
+ }
613
+ GUIDES_PAYLOAD_CACHE = { key: cacheKey, payload: data };
614
+ return data;
615
+ }
616
+
617
+ async function downloadAndSyncGuides(cwd, ctx) {
618
+ const payload = await fetchGuidesPayload(cwd, ctx);
619
+ if (!payload) {
620
+ return null;
621
+ }
622
+ return writeGuidePayloadIntoRepo(cwd, payload);
623
+ }
624
+
625
+ // Ensure the current account repo has the latest guides. Authenticates first
626
+ // (interactive terminals only) so guides are only ever delivered to users who
627
+ // can authenticate to the platform.
628
+ async function ensureGuidesSynced(cwd, flags) {
629
+ if (!loadAccountInfo(cwd)) {
630
+ return; // Guides are only written into account repos.
631
+ }
632
+
633
+ let accountId = null;
634
+ const fromFlag = Number(flags && flags['account-id']);
635
+ if (Number.isFinite(fromFlag) && fromFlag > 0) {
636
+ accountId = fromFlag;
637
+ }
638
+ if (!accountId) {
639
+ try { accountId = loadAccountId(cwd); } catch (_) { /* not in a repo */ }
640
+ }
641
+
642
+ const requestedBaseUrl = flags && flags['base-url'] ? normalizeBaseUrl(flags['base-url']) : null;
643
+ const sessionMatch = { accountId, baseUrl: requestedBaseUrl };
644
+ const sessionOptions = { strictBaseUrl: Boolean(requestedBaseUrl) };
645
+ let session = readSession(sessionMatch, sessionOptions);
646
+
647
+ if (!session || !session.token) {
648
+ // Auto-authenticate, but only when we can safely open a browser. In
649
+ // non-interactive contexts (CI, agent sandboxes) skip silently and let the
650
+ // invoked command surface its own auth guidance.
651
+ if (!process.stdout || !process.stdout.isTTY) {
652
+ return;
653
+ }
654
+ try {
655
+ await authCommand(flags); // authCommand syncs guides on success.
656
+ } catch (_) {
657
+ // Auth failed or was cancelled; leave guides untouched.
658
+ }
659
+ return;
660
+ }
661
+
662
+ const baseUrl = requestedBaseUrl || session.baseUrl || DEFAULT_BASE_URL;
663
+ await downloadAndSyncGuides(cwd, {
664
+ session,
665
+ baseUrl,
666
+ accountId: accountId || session.accountId
667
+ });
668
+
669
+ // Authenticated users also get the core Remits platform repo locally so they
670
+ // can analyze back-stage seams when diagnosing platform brittleness. Pull it
671
+ // up-to-date so analysis runs against current code.
672
+ try { ensurePlatformRepoLocal({ allowClone: true, pull: true }); } catch (_) { /* best-effort */ }
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Remits platform repo (back stage)
677
+ //
678
+ // Anyone with a Remits auth account + remits-cli also gets the core platform
679
+ // repo locally, so they can analyze back-stage seams (and open a fix PR) when a
680
+ // front-stage failure turns out to be platform brittleness. The repo is tracked
681
+ // in the account-repo inventory under the reserved `platform` key.
682
+ // ---------------------------------------------------------------------------
683
+
684
+ const PLATFORM_REPO_GIT_URL = process.env.REMITS_PLATFORM_GIT_URL || 'git@github.com:tmillhouse/remits.git';
685
+ const PLATFORM_REPO_INVENTORY_KEY = 'platform';
686
+ let PLATFORM_REPO_CLONE_ATTEMPTED = false;
687
+ let PLATFORM_REPO_PULL_ATTEMPTED = false;
688
+
689
+ function defaultPlatformRepoDir() {
690
+ const envDir = process.env.REMITS_PLATFORM_DIR;
691
+ if (envDir) {
692
+ return path.resolve(envDir);
693
+ }
694
+ return path.join(os.homedir(), 'remits');
695
+ }
696
+
697
+ function looksLikePlatformRepo(dir) {
698
+ if (!dir) {
699
+ return false;
700
+ }
701
+ try {
702
+ return fs.existsSync(path.join(dir, 'build.gradle'))
703
+ && fs.existsSync(path.join(dir, 'grails-app'))
704
+ && fs.existsSync(path.join(dir, 'cli', 'index.js'));
705
+ } catch (_) {
706
+ return false;
707
+ }
708
+ }
709
+
710
+ function getPlatformRepoEntry(index) {
711
+ const idx = index || readAccountRepoIndex();
712
+ const entry = idx[PLATFORM_REPO_INVENTORY_KEY];
713
+ return entry && entry.directory ? entry : null;
714
+ }
715
+
716
+ function setPlatformRepoEntry(directory, source) {
717
+ const index = readAccountRepoIndex();
718
+ index[PLATFORM_REPO_INVENTORY_KEY] = {
719
+ type: 'PLATFORM_REPO',
720
+ name: 'Remits Platform',
721
+ directory: path.resolve(directory),
722
+ gitUrl: PLATFORM_REPO_GIT_URL,
723
+ source: source || 'detected',
724
+ updatedAt: new Date().toISOString()
725
+ };
726
+ writeAccountRepoIndex(index);
727
+ return index[PLATFORM_REPO_INVENTORY_KEY];
728
+ }
729
+
730
+ // Fast-forward an existing platform repo so back-stage analysis runs against
731
+ // up-to-date code. Best-effort and safe: skips a dirty tree (never clobbers
732
+ // local work) and uses --ff-only so it no-ops rather than creating merge
733
+ // commits when the branch has diverged. Runs at most once per process.
734
+ function maybePullPlatformRepo(dir, options) {
735
+ if (!options || !options.pull || PLATFORM_REPO_PULL_ATTEMPTED) {
736
+ return;
737
+ }
738
+ PLATFORM_REPO_PULL_ATTEMPTED = true;
739
+ try {
740
+ const dirty = execSync('git -C ' + shellQuote(dir) + ' status --porcelain', { stdio: ['ignore', 'pipe', 'ignore'] })
741
+ .toString().trim();
742
+ if (dirty) {
743
+ console.log('[platform] Skipping git pull: ' + dir + ' has uncommitted changes.');
744
+ return;
745
+ }
746
+ console.log('[platform] Updating Remits platform repo (git pull)...');
747
+ execSync('git -C ' + shellQuote(dir) + ' pull --ff-only', { stdio: 'inherit' });
748
+ } catch (err) {
749
+ console.log('[platform] git pull did not complete cleanly (leaving the repo as-is): ' + (err && err.message ? err.message : String(err)));
750
+ }
751
+ }
752
+
753
+ // Resolve the local platform repo, cloning it if necessary. Detection is cheap
754
+ // and always runs; the (one-time, heavier) clone only runs when allowed and in
755
+ // an interactive terminal so CI/agent sandboxes are never blocked on it. When
756
+ // `options.pull` is set, an already-present repo is fast-forwarded so analysis
757
+ // runs against current code.
758
+ function ensurePlatformRepoLocal(options = {}) {
759
+ // 1. Already recorded and still present.
760
+ const recorded = getPlatformRepoEntry();
761
+ if (recorded && looksLikePlatformRepo(recorded.directory)) {
762
+ maybePullPlatformRepo(recorded.directory, options);
763
+ return recorded.directory;
764
+ }
765
+
766
+ // 2. Detect an existing local copy: env override, the running CLI source
767
+ // (dev mode), or the default clone directory.
768
+ const candidates = [
769
+ process.env.REMITS_PLATFORM_DIR ? path.resolve(process.env.REMITS_PLATFORM_DIR) : null,
770
+ path.resolve(__dirname, '..'),
771
+ defaultPlatformRepoDir()
772
+ ].filter(Boolean);
773
+ for (const dir of candidates) {
774
+ if (looksLikePlatformRepo(dir)) {
775
+ setPlatformRepoEntry(dir, 'detected');
776
+ maybePullPlatformRepo(dir, options);
777
+ return dir;
778
+ }
779
+ }
780
+
781
+ // 3. Clone it (one-time, interactive only).
782
+ if (!options.allowClone || PLATFORM_REPO_CLONE_ATTEMPTED) {
783
+ return null;
784
+ }
785
+ if (!process.stdout || !process.stdout.isTTY) {
786
+ return null;
787
+ }
788
+ PLATFORM_REPO_CLONE_ATTEMPTED = true;
789
+
790
+ const target = defaultPlatformRepoDir();
791
+ if (fs.existsSync(target)) {
792
+ console.log('[platform] Skipping clone: ' + target + ' already exists but is not the Remits platform repo. Set REMITS_PLATFORM_DIR to its location.');
793
+ return null;
794
+ }
795
+ try {
796
+ console.log('[platform] Cloning Remits platform repo into ' + target + ' (one-time)...');
797
+ ensureDir(path.dirname(target));
798
+ execSync('git clone ' + shellQuote(PLATFORM_REPO_GIT_URL) + ' ' + shellQuote(target), { stdio: 'inherit' });
799
+ } catch (err) {
800
+ console.log('[platform] Could not clone the Remits platform repo automatically: ' + (err && err.message ? err.message : String(err)));
801
+ console.log('[platform] Clone it manually with: git clone ' + PLATFORM_REPO_GIT_URL + ' ' + target);
802
+ return null;
803
+ }
804
+ if (looksLikePlatformRepo(target)) {
805
+ setPlatformRepoEntry(target, 'cloned');
806
+ console.log('[platform] Remits platform repo available at ' + target);
807
+ return target;
808
+ }
809
+ return null;
810
+ }
811
+
812
+ // Account-repo inventory helpers that exclude the reserved platform entry, so
813
+ // account-only consumers (dashboard, counts) never trip over it.
814
+ function listAccountRepoEntries(index) {
815
+ const idx = index || readAccountRepoIndex();
816
+ return Object.values(idx).filter((entry) => entry && entry.directory && Number.isFinite(Number(entry.accountId)));
817
+ }
818
+
819
+ function countAccountRepos(index) {
820
+ return listAccountRepoEntries(index).length;
821
+ }
822
+
823
+ function mergeAccountRepoEntries(entries, options = {}) {
824
+ const nextIndex = options.existingIndex ? { ...options.existingIndex } : {};
825
+ for (const entry of entries) {
826
+ if (!entry || !entry.accountId || !entry.directory) {
827
+ continue;
828
+ }
829
+ nextIndex[String(entry.accountId)] = entry;
830
+ }
831
+ writeAccountRepoIndex(nextIndex);
832
+ return nextIndex;
833
+ }
834
+
835
+ function updateAccountRepoIndex(cwd) {
836
+ const info = loadAccountInfo(cwd);
837
+ if (!info) {
838
+ return;
839
+ }
840
+ try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
841
+ const entry = buildAccountRepoEntryFromInfo(info, cwd, 'cwd');
842
+ if (!entry) {
843
+ return;
844
+ }
845
+ mergeAccountRepoEntries([entry], { existingIndex: readAccountRepoIndex() });
846
+ }
847
+
848
+ function defaultAccountScanRoots() {
849
+ const envRoots = String(process.env.REMITS_CLI_SCAN_ROOTS || '')
850
+ .split(path.delimiter)
851
+ .map((value) => value.trim())
852
+ .filter(Boolean);
853
+ if (envRoots.length) {
854
+ return envRoots.filter((dir) => fs.existsSync(dir));
855
+ }
856
+ const home = os.homedir();
857
+ return [home].filter((dir) => fs.existsSync(dir));
858
+ }
859
+
860
+ function walkForAccountInfo(dir, depth, matches, visited, platformMatches) {
861
+ const resolvedDir = path.resolve(dir);
862
+ if (visited.has(resolvedDir)) {
863
+ return;
864
+ }
865
+ visited.add(resolvedDir);
866
+
867
+ let entries;
868
+ try {
869
+ entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
870
+ } catch (_) {
871
+ return;
872
+ }
873
+
874
+ const names = new Set(entries.map((entry) => entry.name));
875
+ for (const entry of entries) {
876
+ if (entry.isFile() && entry.name === 'account-info.json') {
877
+ matches.push(path.join(resolvedDir, entry.name));
878
+ }
879
+ }
880
+ // Cheap marker check from the directory listing we already read — detect the
881
+ // Remits platform repo so it lands in the inventory even though it has no
882
+ // account-info.json.
883
+ if (platformMatches && names.has('build.gradle') && names.has('grails-app')
884
+ && names.has('settings.gradle') && names.has('cli')) {
885
+ platformMatches.push(resolvedDir);
886
+ }
887
+
888
+ if (depth >= DEFAULT_SCAN_MAX_DEPTH) {
889
+ return;
890
+ }
891
+
892
+ for (const entry of entries) {
893
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
894
+ continue;
895
+ }
896
+ if (ACCOUNT_SCAN_EXCLUDE_DIRS.has(entry.name)) {
897
+ continue;
898
+ }
899
+ if (entry.name.startsWith('.') && entry.name !== '.config') {
900
+ continue;
901
+ }
902
+ walkForAccountInfo(path.join(resolvedDir, entry.name), depth + 1, matches, visited, platformMatches);
903
+ }
904
+ }
905
+
906
+ function discoverAccountRepos() {
907
+ const matches = [];
908
+ const platformMatches = [];
909
+ const visited = new Set();
910
+ const roots = defaultAccountScanRoots();
911
+ for (const root of roots) {
912
+ walkForAccountInfo(root, 0, matches, visited, platformMatches);
913
+ }
914
+ try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
915
+
916
+ const entries = [];
917
+ for (const file of matches) {
918
+ try {
919
+ const repoDir = path.dirname(file);
920
+ const info = JSON.parse(fs.readFileSync(file, 'utf8'));
921
+ const entry = buildAccountRepoEntryFromInfo(info, repoDir, 'scan');
922
+ if (entry) {
923
+ entries.push(entry);
924
+ }
925
+ } catch (_) {
926
+ // Skip invalid files during discovery.
927
+ }
928
+ }
929
+
930
+ // Record an already-present platform repo from the scan if one isn't tracked
931
+ // yet; otherwise fall back to detection/clone.
932
+ if (!getPlatformRepoEntry()) {
933
+ const found = platformMatches.find(looksLikePlatformRepo);
934
+ if (found) {
935
+ setPlatformRepoEntry(found, 'scan');
936
+ } else {
937
+ try { ensurePlatformRepoLocal({ allowClone: true }); } catch (_) { /* best-effort */ }
938
+ }
939
+ }
940
+
941
+ const index = mergeAccountRepoEntries(entries, { existingIndex: readAccountRepoIndex() });
942
+ return {
943
+ roots,
944
+ matches: matches.length,
945
+ repoCount: countAccountRepos(index),
946
+ entries: index,
947
+ scannedAt: new Date().toISOString()
948
+ };
949
+ }
950
+
951
+ function resolvePreferredAccountId(cwd, flags, session) {
952
+ // Explicit --account-id wins over the repo's account-info.json so users can
953
+ // target a child account (e.g. a customer) from a parent-account repo.
954
+ const fromFlag = Number(flags && flags['account-id']);
955
+ if (Number.isFinite(fromFlag) && fromFlag > 0) {
956
+ return fromFlag;
957
+ }
958
+
959
+ try {
960
+ const fromRepo = loadAccountId(cwd);
961
+ if (Number.isFinite(fromRepo) && fromRepo > 0) {
962
+ try { updateAccountRepoIndex(cwd); } catch (_) { /* best-effort */ }
963
+ return fromRepo;
964
+ }
965
+ } catch (_) {
966
+ // Fall back to session when not in an account repo.
967
+ }
968
+
969
+ if (session && session.accountId) {
970
+ const fromSession = Number(session.accountId);
971
+ if (Number.isFinite(fromSession) && fromSession > 0) {
972
+ return fromSession;
973
+ }
974
+ }
975
+
976
+ throw new Error('Unable to resolve account id. Use an account repo with account-info.json or pass --account-id.');
977
+ }
978
+
979
+ function resolveSessionContext(cwd, flags) {
980
+ // Explicit --account-id wins over the repo's account-info.json so users can
981
+ // target a child account (e.g. a customer) from a parent-account repo.
982
+ let accountId = null;
983
+ const fromFlag = Number(flags && flags['account-id']);
984
+ if (Number.isFinite(fromFlag) && fromFlag > 0) {
985
+ accountId = fromFlag;
986
+ }
987
+
988
+ if (!accountId) {
989
+ try {
990
+ accountId = loadAccountId(cwd);
991
+ try { updateAccountRepoIndex(cwd); } catch (_) { /* best-effort */ }
992
+ } catch (_) { /* not in an account repo */ }
993
+ }
994
+
995
+ const requestedBaseUrl = flags && flags['base-url'] ? normalizeBaseUrl(flags['base-url']) : null;
996
+ const session = readSession({
997
+ accountId,
998
+ baseUrl: requestedBaseUrl,
999
+ dataMode: hasExplicitDataModeFlag(flags) ? resolveDataMode(flags, null) : null
1000
+ }, {
1001
+ strictBaseUrl: Boolean(requestedBaseUrl)
1002
+ });
1003
+ if (!session || !session.token) {
1004
+ if (accountId && requestedBaseUrl) {
1005
+ throw new Error('Not authenticated for account ' + accountId + ' at ' + requestedBaseUrl + '. Run: remits-cli auth --base-url ' + requestedBaseUrl + ' --account-id ' + accountId);
1006
+ }
1007
+ throw new Error('Not authenticated. Run: remits-cli auth');
1008
+ }
1009
+
1010
+ // Final accountId resolution: flag > repo > session
1011
+ if (!accountId) {
1012
+ accountId = session.accountId;
1013
+ }
1014
+
1015
+ return { session, accountId };
1016
+ }
1017
+
1018
+ function collectComponents(cwd) {
1019
+ const mapping = {
1020
+ schemas: 'schema',
1021
+ readers: 'reader',
1022
+ actions: 'action',
1023
+ embeddables: 'embeddable',
1024
+ rules: 'rule',
1025
+ templates: 'htmltemplate',
1026
+ agents: 'utility',
1027
+ tools: 'tool',
1028
+ prompts: 'prompt',
1029
+ tests: 'test'
1030
+ };
1031
+
1032
+ const byKey = new Map();
1033
+ for (const dirName of Object.keys(mapping)) {
1034
+ const type = mapping[dirName];
1035
+ const dir = path.join(cwd, 'components', dirName);
1036
+ if (!fs.existsSync(dir)) {
1037
+ continue;
1038
+ }
1039
+
1040
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile());
1041
+ for (const entry of entries) {
1042
+ const fileName = entry.name;
1043
+ const metaMatch = fileName.match(/^(.+?)_(.+)\.meta\.ya?ml$/i);
1044
+ const match = metaMatch || fileName.match(/^(.+?)_(.+)\.([^.]+)$/);
1045
+ if (!match) {
1046
+ continue;
1047
+ }
1048
+ const prefix = match[1];
1049
+ const rawName = match[2];
1050
+ const ext = metaMatch ? 'meta' : match[3].toLowerCase();
1051
+ const id = /^\d+$/.test(prefix) ? Number(prefix) : null;
1052
+ const name = prefix.startsWith('new') || !id ? normalizeName(rawName) : normalizeName(rawName);
1053
+ const key = type + ':' + (id ? 'id:' + id : 'name:' + name.toLowerCase());
1054
+
1055
+ if (!byKey.has(key)) {
1056
+ byKey.set(key, { type, id, name });
1057
+ }
1058
+ const component = byKey.get(key);
1059
+ const filePath = path.join(dir, fileName);
1060
+ const content = fs.readFileSync(filePath, 'utf8');
1061
+
1062
+ if (ext === 'meta') {
1063
+ try {
1064
+ const meta = YAML.parse(content);
1065
+ if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
1066
+ for (const [metaKey, metaValue] of Object.entries(meta)) {
1067
+ if (metaKey === 'type') {
1068
+ component.metaType = metaValue;
1069
+ } else {
1070
+ component[metaKey] = metaValue;
1071
+ }
1072
+ }
1073
+ }
1074
+ } catch (err) {
1075
+ throw new Error('Failed to parse component metadata ' + filePath + ': ' + err.message);
1076
+ }
1077
+ } else if (type === 'utility' && ext === 'md') {
1078
+ component.prompt = content;
1079
+ } else if (type === 'prompt' && ext === 'md') {
1080
+ component.prompt = content;
1081
+ } else if (ext === 'groovy' || ext === 'md') {
1082
+ component.source = content;
1083
+ } else if (type === 'embeddable' && ext === 'html') {
1084
+ component.html = content;
1085
+ } else if (type === 'embeddable' && ext === 'js') {
1086
+ component.javascript = content;
1087
+ } else if (type === 'htmltemplate' && ext === 'html') {
1088
+ component.html = content;
1089
+ } else if (type === 'htmltemplate' && ext === 'json') {
1090
+ component.previewData = content;
1091
+ } else if (type === 'tool' && ext === 'json') {
1092
+ component.inputSchema = content;
1093
+ } else if (type === 'schema' && ext === 'json') {
1094
+ component.schema = content;
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ const components = [];
1100
+ for (const component of byKey.values()) {
1101
+ const fingerprint = {};
1102
+ Object.keys(component).sort().forEach((key) => {
1103
+ if (key !== 'hash') {
1104
+ fingerprint[key] = component[key] == null ? null : component[key];
1105
+ }
1106
+ });
1107
+ component.hash = sha256(stableStringify(fingerprint));
1108
+ components.push(component);
1109
+ }
1110
+
1111
+ return components;
1112
+ }
1113
+
1114
+ function buildAxios(baseUrl, token) {
1115
+ const headers = token ? { Authorization: 'Bearer ' + token } : {};
1116
+ return axios.create({ baseURL: baseUrl, timeout: 60000, headers });
1117
+ }
1118
+
1119
+ function describeError(err) {
1120
+ if (!err) {
1121
+ return 'Unknown error';
1122
+ }
1123
+ const parts = [];
1124
+ if (err.response && err.response.data) {
1125
+ const responseData = err.response.data;
1126
+ if (responseData.message) {
1127
+ parts.push(String(responseData.message));
1128
+ } else if (typeof responseData === 'string') {
1129
+ parts.push(responseData);
1130
+ }
1131
+ }
1132
+ if (err.message) {
1133
+ parts.push(String(err.message));
1134
+ }
1135
+ if (Array.isArray(err.errors)) {
1136
+ for (const nested of err.errors) {
1137
+ if (nested && nested.message) {
1138
+ parts.push(String(nested.message));
1139
+ } else if (nested) {
1140
+ parts.push(String(nested));
1141
+ }
1142
+ }
1143
+ }
1144
+ if (err.code) {
1145
+ parts.push(String(err.code));
1146
+ }
1147
+ const unique = [];
1148
+ for (const part of parts.map((value) => value.trim()).filter(Boolean)) {
1149
+ if (!unique.includes(part)) {
1150
+ unique.push(part);
1151
+ }
1152
+ }
1153
+ if (unique.length) {
1154
+ return unique.join(' | ');
1155
+ }
1156
+ if (err.name) {
1157
+ return String(err.name);
1158
+ }
1159
+ return String(err);
1160
+ }
1161
+
1162
+ function isLikelySandboxNetworkError(err) {
1163
+ const networkCodes = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'EPERM'];
1164
+ const directCode = err && err.code ? String(err.code).toUpperCase() : '';
1165
+ if (networkCodes.includes(directCode)) {
1166
+ return true;
1167
+ }
1168
+ if (Array.isArray(err && err.errors)) {
1169
+ return err.errors.some((nested) => {
1170
+ const nestedCode = nested && nested.code ? String(nested.code).toUpperCase() : '';
1171
+ return networkCodes.includes(nestedCode);
1172
+ });
1173
+ }
1174
+ return false;
1175
+ }
1176
+
1177
+ function sandboxNetworkHint(err) {
1178
+ if (!isLikelySandboxNetworkError(err)) return '';
1179
+ const baseUrl = err && err.config && err.config.baseURL ? err.config.baseURL : DEFAULT_BASE_URL;
1180
+ return ' Network access to ' + baseUrl + ' appears unavailable from the current sandbox. If remits-cli is being run by a local AI agent (for example Codex in tmux), retry the command with escalated permissions or outside the sandbox.';
1181
+ }
1182
+
1183
+ function saveToolsSnapshot(cwd, data) {
1184
+ const paths = ensureLocalState(cwd);
1185
+ const file = path.join(paths.toolsDir, 'tools.json');
1186
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
1187
+ return file;
1188
+ }
1189
+
1190
+ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
1191
+ const requestId = options.requestId || crypto.randomUUID();
1192
+ const started = Date.now();
1193
+ let responseData = null;
1194
+ let error = null;
1195
+ let responseFile = null;
1196
+
1197
+ try {
1198
+ responseData = await api.post(endpoint, payload).then((r) => r.data);
1199
+ if (options.toolResponseFile) {
1200
+ const callId = options.toolCallId || (responseData && responseData.callId) || requestId;
1201
+ responseFile = writeToolResponseFile(cwd, callId, responseData);
1202
+ }
1203
+ return { data: responseData, requestId, responseFile };
1204
+ } catch (err) {
1205
+ responseData = err && err.response ? err.response.data : null;
1206
+ error = err;
1207
+ throw err;
1208
+ } finally {
1209
+ const status = error ? 'error' : 'success';
1210
+ const responseForLog = responseFile
1211
+ ? { responseFile, note: 'Tool response stored externally' }
1212
+ : sanitizeForLog(responseData);
1213
+ appendSessionLog(cwd, {
1214
+ ts: new Date().toISOString(),
1215
+ requestId,
1216
+ endpoint,
1217
+ method: 'POST',
1218
+ status,
1219
+ durationMs: Date.now() - started,
1220
+ request: sanitizeForLog(payload),
1221
+ response: responseForLog,
1222
+ error: error ? describeError(error) : null
1223
+ });
1224
+ }
1225
+ }
1226
+
1227
+ async function loggedGet(api, cwd, endpoint, params = {}) {
1228
+ const requestId = crypto.randomUUID();
1229
+ const started = Date.now();
1230
+ let responseData = null;
1231
+ let error = null;
1232
+ try {
1233
+ responseData = await api.get(endpoint, { params }).then((r) => r.data);
1234
+ return { data: responseData, requestId };
1235
+ } catch (err) {
1236
+ responseData = err && err.response ? err.response.data : null;
1237
+ error = err;
1238
+ throw err;
1239
+ } finally {
1240
+ appendSessionLog(cwd, {
1241
+ ts: new Date().toISOString(),
1242
+ requestId,
1243
+ endpoint,
1244
+ method: 'GET',
1245
+ status: error ? 'error' : 'success',
1246
+ durationMs: Date.now() - started,
1247
+ request: sanitizeForLog(params),
1248
+ response: sanitizeForLog(responseData),
1249
+ error: error ? describeError(error) : null
1250
+ });
1251
+ }
1252
+ }
1253
+
1254
+ function resolveSkillTargets(target) {
1255
+ const value = String(target || 'all').toLowerCase();
1256
+ const home = os.homedir();
1257
+ const targets = {
1258
+ codex: path.join(home, '.codex', 'skills', 'remits-cli', 'SKILL.md'),
1259
+ claude: path.join(home, '.claude', 'skills', 'remits-cli', 'SKILL.md'),
1260
+ gemini: path.join(home, '.gemini', 'skills', 'remits-cli.md')
1261
+ };
1262
+
1263
+ if (value === 'all') {
1264
+ return Object.entries(targets);
1265
+ }
1266
+ if (!targets[value]) {
1267
+ throw new Error('Unknown --target value: ' + target + ' (expected codex|claude|gemini|all)');
1268
+ }
1269
+ return [[value, targets[value]]];
1270
+ }
1271
+
1272
+ function buildSkillSourceBody() {
1273
+ if (!fs.existsSync(SKILL_SOURCE_FILE)) {
1274
+ throw new Error('Skill source file not found: ' + SKILL_SOURCE_FILE);
1275
+ }
1276
+
1277
+ let sourceBody = fs.readFileSync(SKILL_SOURCE_FILE, 'utf8');
1278
+ sourceBody = sourceBody.replace('{{ACCOUNT_REPO_INDEX_PATH}}', ACCOUNT_REPO_INDEX_FILE);
1279
+ return sourceBody;
1280
+ }
1281
+
1282
+ function syncInstalledSkills(target = 'all') {
1283
+ const targets = resolveSkillTargets(target);
1284
+ const sourceBody = buildSkillSourceBody();
1285
+ const summary = {
1286
+ updated: [],
1287
+ failed: []
1288
+ };
1289
+
1290
+ for (const [agent, targetPath] of targets) {
1291
+ try {
1292
+ ensureDir(path.dirname(targetPath));
1293
+ if (writeFileIfChanged(targetPath, sourceBody)) {
1294
+ summary.updated.push({ agent, path: targetPath });
1295
+ }
1296
+ } catch (error) {
1297
+ summary.failed.push({
1298
+ agent,
1299
+ path: targetPath,
1300
+ reason: error && error.message ? error.message : String(error)
1301
+ });
1302
+ }
1303
+ }
1304
+
1305
+ return summary;
1306
+ }
1307
+
1308
+ async function installSkillsCommand(flags) {
1309
+ if (!flags.skills) {
1310
+ throw new Error('Missing --skills flag. Usage: remits-cli install --skills');
1311
+ }
1312
+
1313
+ const overwrite = flags.overwrite === true || flags.overwrite === 'true';
1314
+ const targets = resolveSkillTargets(flags.target || 'all');
1315
+ const sourceBody = buildSkillSourceBody();
1316
+
1317
+ const installed = [];
1318
+ const skipped = [];
1319
+
1320
+ for (const [agent, targetPath] of targets) {
1321
+ ensureDir(path.dirname(targetPath));
1322
+ if (fs.existsSync(targetPath) && !overwrite) {
1323
+ skipped.push({ agent, path: targetPath, reason: 'already exists (use --overwrite true)' });
1324
+ continue;
1325
+ }
1326
+ atomicWriteFile(targetPath, sourceBody);
1327
+ installed.push({ agent, path: targetPath });
1328
+ }
1329
+
1330
+ console.log('Skill installation complete.');
1331
+ if (installed.length) {
1332
+ console.log('Installed:');
1333
+ for (const item of installed) {
1334
+ console.log(' -', item.agent + ':', item.path);
1335
+ }
1336
+ }
1337
+ if (skipped.length) {
1338
+ console.log('Skipped:');
1339
+ for (const item of skipped) {
1340
+ console.log(' -', item.agent + ':', item.path, '(' + item.reason + ')');
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ async function authCommand(flags) {
1346
+ const cwd = process.cwd();
1347
+ ensureLocalState(cwd);
1348
+ // Explicit --account-id wins over the repo's account-info.json so users can
1349
+ // authenticate into a child account from a parent-account repo.
1350
+ let preAccountId = null;
1351
+ const fromFlag = Number(flags['account-id']);
1352
+ if (Number.isFinite(fromFlag) && fromFlag > 0) preAccountId = fromFlag;
1353
+ if (!preAccountId) {
1354
+ try { preAccountId = loadAccountId(cwd); } catch (_) { /* not in repo */ }
1355
+ }
1356
+ const explicitBaseUrl = flags['base-url'] ? normalizeBaseUrl(flags['base-url']) : null;
1357
+ const existingSession = readSession({
1358
+ accountId: preAccountId,
1359
+ baseUrl: explicitBaseUrl
1360
+ }, {
1361
+ strictBaseUrl: Boolean(explicitBaseUrl)
1362
+ }) || {};
1363
+ const dataMode = resolveDataMode(flags, existingSession);
1364
+ const baseUrl = explicitBaseUrl || normalizeBaseUrl(existingSession.baseUrl || DEFAULT_BASE_URL);
1365
+ const accountId = preAccountId || resolvePreferredAccountId(cwd, flags, {});
1366
+ const preferredPort = Number(flags.port || 8765);
1367
+ const state = crypto.randomUUID();
1368
+ const callback = await new Promise((resolve, reject) => {
1369
+ const createServer = () => {
1370
+ const server = http.createServer((req, res) => {
1371
+ const requestPort = server.address() && typeof server.address() === 'object'
1372
+ ? server.address().port
1373
+ : preferredPort;
1374
+ const reqUrl = new URL(req.url, 'http://localhost:' + requestPort);
1375
+ if (reqUrl.pathname !== '/callback') {
1376
+ res.statusCode = 404;
1377
+ res.end('Not found');
1378
+ return;
1379
+ }
1380
+
1381
+ const payload = {
1382
+ token: reqUrl.searchParams.get('token'),
1383
+ state: reqUrl.searchParams.get('state'),
1384
+ userId: reqUrl.searchParams.get('userId'),
1385
+ userUuid: reqUrl.searchParams.get('userUuid')
1386
+ };
1387
+
1388
+ res.statusCode = 200;
1389
+ res.setHeader('Content-Type', 'text/html');
1390
+ res.end('<html><body><h2>Remits CLI Auth Complete</h2><p>You can close this tab.</p></body></html>');
1391
+ server.close();
1392
+ callbackPromiseResolve(payload);
1393
+ });
1394
+
1395
+ let callbackPromiseResolve;
1396
+ const callbackPromise = new Promise((resolvePayload) => {
1397
+ callbackPromiseResolve = resolvePayload;
1398
+ });
1399
+
1400
+ const listen = (port, allowFallback) => {
1401
+ server.once('error', (err) => {
1402
+ if (allowFallback && err && err.code === 'EADDRINUSE') {
1403
+ const fallbackServer = createServer();
1404
+ fallbackServer.listen(0, false);
1405
+ return;
1406
+ }
1407
+ reject(err);
1408
+ });
1409
+
1410
+ server.listen(port, '127.0.0.1', () => {
1411
+ const address = server.address();
1412
+ const boundPort = address && typeof address === 'object' ? address.port : port;
1413
+ resolve({
1414
+ port: boundPort,
1415
+ callbackPromise
1416
+ });
1417
+ });
1418
+ };
1419
+
1420
+ return { listen };
1421
+ };
1422
+
1423
+ createServer().listen(preferredPort, true);
1424
+ });
1425
+ const port = callback.port;
1426
+ const redirectUri = 'http://localhost:' + port + '/callback';
1427
+ if (port !== preferredPort) {
1428
+ console.log('Preferred auth callback port', preferredPort, 'is unavailable. Using', port);
1429
+ }
1430
+
1431
+ const params = new URLSearchParams();
1432
+ params.set('redirect_uri', redirectUri);
1433
+ params.set('state', state);
1434
+ if (accountId) {
1435
+ params.set('accountId', String(accountId));
1436
+ }
1437
+ params.set('dataMode', dataMode);
1438
+
1439
+ const loginUrl = baseUrl.replace(/\/$/, '') + '/cli/auth?' + params.toString();
1440
+ console.log('Opening browser for auth:', loginUrl);
1441
+ if (openBrowser) {
1442
+ await openBrowser(loginUrl);
1443
+ } else {
1444
+ console.log('Browser auto-open is unavailable. Open this URL manually:', loginUrl);
1445
+ }
1446
+
1447
+ const timeoutPromise = new Promise((_, reject) =>
1448
+ setTimeout(() => reject(new Error('Auth callback timeout (120s)')), 120000)
1449
+ );
1450
+ const result = await Promise.race([callback.callbackPromise, timeoutPromise]);
1451
+
1452
+ if (result.state !== state) {
1453
+ throw new Error('Invalid auth callback state');
1454
+ }
1455
+
1456
+ const api = buildAxios(baseUrl, result.token);
1457
+ const me = await loggedGet(api, cwd, '/cli/auth', {
1458
+ accountId: accountId || undefined,
1459
+ dataMode
1460
+ }).then((r) => r.data);
1461
+
1462
+ const resolvedAccountId = me.account ? me.account.id : Number(accountId);
1463
+ const session = {
1464
+ ...existingSession,
1465
+ baseUrl,
1466
+ token: result.token,
1467
+ accountId: resolvedAccountId,
1468
+ user: me.user,
1469
+ websocketTopic: me.websocketTopic,
1470
+ dataMode: normalizeDataMode(me.dataMode || dataMode),
1471
+ updatedAt: new Date().toISOString()
1472
+ };
1473
+
1474
+ writeSession(resolvedAccountId, session, {
1475
+ baseUrl,
1476
+ dataMode: session.dataMode
1477
+ });
1478
+ console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
1479
+ console.log('Data mode:', session.dataMode);
1480
+
1481
+ try {
1482
+ const toolsData = await loggedPost(api, cwd, '/cli/tools', {
1483
+ token: session.token,
1484
+ accountId: session.accountId,
1485
+ dataMode: session.dataMode
1486
+ }).then((r) => r.data);
1487
+ if (toolsData && toolsData.success) {
1488
+ const toolsFile = saveToolsSnapshot(cwd, {
1489
+ accountId: session.accountId,
1490
+ dataMode: toolsData.dataMode || session.dataMode,
1491
+ fetchedAt: new Date().toISOString(),
1492
+ tools: toolsData.tools || []
1493
+ });
1494
+ console.log('Tools cached at:', toolsFile);
1495
+ }
1496
+ } catch (err) {
1497
+ console.log('Warning: failed to refresh tools after auth:', err.message);
1498
+ }
1499
+
1500
+ // Download the latest front-stage guides and write them into the repo now
1501
+ // that we hold an authenticated session.
1502
+ try {
1503
+ const guideSummary = await downloadAndSyncGuides(cwd, {
1504
+ session,
1505
+ baseUrl,
1506
+ accountId: session.accountId
1507
+ });
1508
+ if (guideSummary && guideSummary.updated && guideSummary.updated.length) {
1509
+ console.log('Guides updated:', guideSummary.updated.length, 'file(s)');
1510
+ }
1511
+ } catch (_) { /* best-effort */ }
1512
+
1513
+ // Ensure the core Remits platform repo is available locally for back-stage
1514
+ // analysis (clone on first authenticated run if missing) and pull it
1515
+ // up-to-date.
1516
+ try { ensurePlatformRepoLocal({ allowClone: true, pull: true }); } catch (_) { /* best-effort */ }
1517
+
1518
+ // Auto-start the background WebSocket listener after successful auth
1519
+ if (!isListenerRunning()) {
1520
+ try {
1521
+ const dashboardUrl = await autoStartServiceIfNeeded();
1522
+ if (dashboardUrl) {
1523
+ console.log('[service] Auto-started in background');
1524
+ console.log('[service] Dashboard:', dashboardUrl);
1525
+ } else {
1526
+ console.log('[service] Auto-start attempt did not stay running. Use `remits-cli start` to inspect startup issues.');
1527
+ }
1528
+ } catch (_) { /* best-effort */ }
1529
+ }
1530
+ }
1531
+
1532
+ async function pushComponentsCommand(flags) {
1533
+ const cwd = process.cwd();
1534
+ ensureLocalState(cwd);
1535
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1536
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1537
+ const branchName = flags.branch || currentBranch(cwd);
1538
+ const dataMode = resolveDataMode(flags, session);
1539
+ const requestedMode = String(flags.mode || 'stage').toLowerCase();
1540
+ const mode = requestedMode === 'push' ? 'stage' : requestedMode;
1541
+ const components = collectComponents(cwd);
1542
+
1543
+ if (requestedMode === 'push') {
1544
+ console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
1545
+ }
1546
+
1547
+ const api = buildAxios(baseUrl, session.token);
1548
+ const response = await loggedPost(api, cwd, '/cli/components', {
1549
+ token: session.token,
1550
+ accountId,
1551
+ branchName,
1552
+ dataMode,
1553
+ mode,
1554
+ components
1555
+ }).then((r) => r.data);
1556
+
1557
+ console.log('Data mode:', response.dataMode || dataMode);
1558
+ console.log('Mode:', response.mode || mode);
1559
+ console.log('Components sync:', JSON.stringify(response, null, 2));
1560
+ }
1561
+
1562
+ async function syncComponentsCommand(flags) {
1563
+ const cwd = process.cwd();
1564
+ ensureLocalState(cwd);
1565
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1566
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1567
+ const branchName = flags.branch || currentBranch(cwd);
1568
+ const dataMode = resolveDataMode(flags, session);
1569
+ const api = buildAxios(baseUrl, session.token);
1570
+
1571
+ const response = await loggedPost(api, cwd, '/cli/components', {
1572
+ token: session.token,
1573
+ accountId,
1574
+ branchName,
1575
+ dataMode,
1576
+ mode: 'sync',
1577
+ components: []
1578
+ }).then((r) => r.data);
1579
+
1580
+ if (!response.success) {
1581
+ throw new Error(response.message || 'Server sync failed');
1582
+ }
1583
+
1584
+ console.log('Data mode:', response.dataMode || dataMode);
1585
+ console.log('Mode:', response.mode || 'sync');
1586
+ console.log('Components sync:', JSON.stringify(response, null, 2));
1587
+ return response;
1588
+ }
1589
+
1590
+ async function commitComponentsCommand(flags) {
1591
+ const cwd = process.cwd();
1592
+ ensureLocalState(cwd);
1593
+ const { accountId } = resolveSessionContext(cwd, flags);
1594
+ const branchName = flags.branch || currentBranch(cwd);
1595
+ const commitMessage = String(flags.message || ('remits-cli commit sync ' + new Date().toISOString()));
1596
+ const allowEmpty = flags['allow-empty'] === true || flags['allow-empty'] === 'true';
1597
+ const skipGit = flags['skip-git'] === true || flags['skip-git'] === 'true';
1598
+
1599
+ if (!skipGit) {
1600
+ console.log('Phase 1/3: local git commit/push');
1601
+ const status = runGit(cwd, 'git status --porcelain');
1602
+ if (status || allowEmpty) {
1603
+ runGit(cwd, 'git add -A');
1604
+ const commitCmd = 'git commit ' + (allowEmpty ? '--allow-empty ' : '') + '-m ' + shellQuote(commitMessage);
1605
+ try {
1606
+ runGit(cwd, commitCmd);
1607
+ } catch (err) {
1608
+ if (!allowEmpty && String(err.message || '').toLowerCase().includes('nothing to commit')) {
1609
+ console.log('No staged changes to commit.');
1610
+ } else {
1611
+ throw err;
1612
+ }
1613
+ }
1614
+ } else {
1615
+ console.log('No local changes detected; skipping local git commit.');
1616
+ }
1617
+
1618
+ try {
1619
+ runGit(cwd, 'git push origin ' + shellQuote(branchName));
1620
+ } catch (err) {
1621
+ runGit(cwd, 'git push --set-upstream origin ' + shellQuote(branchName));
1622
+ }
1623
+ } else {
1624
+ console.log('Skipping local git phase. Prefer `remits-cli components sync` if you only need the server sync step.');
1625
+ }
1626
+
1627
+ console.log('Phase 2/3: server sync');
1628
+ const syncResponse = await syncComponentsCommand({
1629
+ ...flags,
1630
+ branch: branchName,
1631
+ 'account-id': accountId
1632
+ });
1633
+
1634
+ if (!skipGit) {
1635
+ console.log('Phase 3/3: local fast-forward pull');
1636
+ runGit(cwd, 'git fetch origin ' + shellQuote(branchName));
1637
+ const expectedSha = syncResponse && syncResponse.sync && syncResponse.sync.postSyncSha;
1638
+ if (expectedSha) {
1639
+ const fetchedSha = currentHeadSha(cwd, 'origin/' + branchName);
1640
+ if (fetchedSha !== expectedSha) {
1641
+ throw new Error(
1642
+ 'Remote branch head changed after sync. Expected ' + expectedSha + ' from remits sync, but origin/' + branchName + ' is ' + fetchedSha + '. Stop and investigate before pulling.'
1643
+ );
1644
+ }
1645
+ console.log('Verified remote branch head:', expectedSha);
1646
+ }
1647
+ runGit(cwd, 'git pull --ff-only origin ' + shellQuote(branchName));
1648
+ if (expectedSha) {
1649
+ const localSha = currentHeadSha(cwd);
1650
+ if (localSha !== expectedSha) {
1651
+ throw new Error(
1652
+ 'Local HEAD does not match remits sync SHA after pull. Expected ' + expectedSha + ', got ' + localSha + '.'
1653
+ );
1654
+ }
1655
+ console.log('Verified local HEAD matches remits sync SHA:', expectedSha);
1656
+ }
1657
+ }
1658
+ }
1659
+
1660
+ function webSocketUrl(baseUrl) {
1661
+ const u = new URL(baseUrl);
1662
+ const wsProtocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
1663
+ return wsProtocol + '//' + u.host + '/stomp';
1664
+ }
1665
+
1666
+ async function waitForStatus(api, cwd, accountId, branchName, taskId, token, dataMode) {
1667
+ let pollDelayMs = 1000;
1668
+ while (true) {
1669
+ const status = await loggedPost(api, cwd, '/cli/test', {
1670
+ token,
1671
+ command: 'status',
1672
+ accountId,
1673
+ branchName,
1674
+ taskId,
1675
+ dataMode
1676
+ }).then((r) => r.data);
1677
+
1678
+ if (status.status === 'completed' || status.status === 'failed') {
1679
+ return status;
1680
+ }
1681
+ await new Promise((r) => setTimeout(r, pollDelayMs));
1682
+ pollDelayMs = Math.min(5000, pollDelayMs + 500);
1683
+ }
1684
+ }
1685
+
1686
+ function watchWebsocket(baseUrl, topicId, taskId) {
1687
+ const client = new Client({
1688
+ brokerURL: webSocketUrl(baseUrl),
1689
+ reconnectDelay: 3000,
1690
+ heartbeatIncoming: 10000,
1691
+ heartbeatOutgoing: 10000,
1692
+ debug: () => {}
1693
+ });
1694
+
1695
+ client.onConnect = () => {
1696
+ client.subscribe('/topic/messages/' + topicId, (msg) => {
1697
+ try {
1698
+ const data = JSON.parse(msg.body);
1699
+ if (data.type === 'TestSuite') {
1700
+ const payload = data.payload || {};
1701
+ if (payload.taskId && taskId && payload.taskId !== taskId) {
1702
+ return;
1703
+ }
1704
+ if (payload.tests || payload.name || payload.total != null) {
1705
+ console.log('[ws][TestSuite]', JSON.stringify(payload));
1706
+ }
1707
+ }
1708
+ } catch (err) {
1709
+ console.error('[ws] parse error:', err.message);
1710
+ }
1711
+ });
1712
+ };
1713
+
1714
+ client.activate();
1715
+ return () => {
1716
+ client.deactivate();
1717
+ };
1718
+ }
1719
+
1720
+ async function testCommand(flags) {
1721
+ const cwd = process.cwd();
1722
+ ensureLocalState(cwd);
1723
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1724
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1725
+ const branchName = flags.branch || currentBranch(cwd);
1726
+ const dataMode = resolveDataMode(flags, session);
1727
+ const testRef = flags.test || flags['test-id'] || flags.name;
1728
+
1729
+ if (!testRef) {
1730
+ throw new Error('Missing --test <id-or-name>');
1731
+ }
1732
+
1733
+ const names = flags.names ? String(flags.names).split(',').map((s) => s.trim()).filter(Boolean) : [];
1734
+
1735
+ const api = buildAxios(baseUrl, session.token);
1736
+ const start = await loggedPost(api, cwd, '/cli/test', {
1737
+ token: session.token,
1738
+ accountId,
1739
+ branchName,
1740
+ dataMode,
1741
+ testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
1742
+ testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
1743
+ tests: names
1744
+ }).then((r) => r.data);
1745
+
1746
+ if (!start.success) {
1747
+ throw new Error('Failed to start test run');
1748
+ }
1749
+
1750
+ console.log('Test run started:', start.taskId);
1751
+ runtimeState.currentTestTaskId = start.taskId;
1752
+
1753
+ let stopWs = null;
1754
+ if (flags.watch !== 'false') {
1755
+ const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
1756
+ if (topic) {
1757
+ stopWs = watchWebsocket(baseUrl, topic, start.taskId);
1758
+ }
1759
+ }
1760
+
1761
+ const status = await waitForStatus(api, cwd, accountId, branchName, start.taskId, session.token, dataMode);
1762
+ if (stopWs) {
1763
+ stopWs();
1764
+ }
1765
+ if (runtimeState.currentTestTaskId === start.taskId) {
1766
+ runtimeState.currentTestTaskId = null;
1767
+ }
1768
+
1769
+ console.log('Final status:', JSON.stringify(status, null, 2));
1770
+ if (status.status !== 'completed') {
1771
+ process.exitCode = 1;
1772
+ } else if (status.result && status.result.failed > 0) {
1773
+ process.exitCode = 1;
1774
+ }
1775
+ }
1776
+
1777
+ async function tokenCommand(flags) {
1778
+ const cwd = process.cwd();
1779
+ ensureLocalState(cwd);
1780
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1781
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1782
+ const branchName = flags.branch || currentBranch(cwd);
1783
+ const dataMode = resolveDataMode(flags, session);
1784
+
1785
+ const api = buildAxios(baseUrl, session.token);
1786
+ const data = await loggedPost(api, cwd, '/cli/token', {
1787
+ token: session.token,
1788
+ accountId,
1789
+ branchName,
1790
+ dataMode
1791
+ }).then((r) => r.data);
1792
+
1793
+ if (!data.success) {
1794
+ throw new Error('Failed to generate token key');
1795
+ }
1796
+
1797
+ const base = baseUrl.replace(/\/$/, '');
1798
+ const embeddablePath = flags.path || flags['embeddable-path'];
1799
+ const embeddableUrl = embeddablePath
1800
+ ? (base + '/s/' + data.tokenKey + '/' + String(embeddablePath).replace(/^\/+/, ''))
1801
+ : null;
1802
+
1803
+ const output = {
1804
+ success: true,
1805
+ tokenKey: data.tokenKey,
1806
+ accountId: data.accountId,
1807
+ branchName: data.branchName,
1808
+ dataMode: data.dataMode || dataMode,
1809
+ shortBasePath: data.shortBasePath,
1810
+ embeddableUrl
1811
+ };
1812
+
1813
+ console.log(JSON.stringify(output, null, 2));
1814
+ }
1815
+
1816
+ async function toolsCommand(flags) {
1817
+ const cwd = process.cwd();
1818
+ ensureLocalState(cwd);
1819
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1820
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1821
+ const dataMode = resolveDataMode(flags, session);
1822
+ const api = buildAxios(baseUrl, session.token);
1823
+
1824
+ const data = await loggedPost(api, cwd, '/cli/tools', {
1825
+ token: session.token,
1826
+ accountId,
1827
+ dataMode
1828
+ }).then((r) => r.data);
1829
+
1830
+ if (!data.success) {
1831
+ throw new Error('Failed to fetch tools');
1832
+ }
1833
+
1834
+ const toolsFile = saveToolsSnapshot(cwd, {
1835
+ accountId,
1836
+ dataMode: data.dataMode || dataMode,
1837
+ fetchedAt: new Date().toISOString(),
1838
+ tools: data.tools || []
1839
+ });
1840
+
1841
+ console.log('Tools refreshed.');
1842
+ console.log('Data mode:', data.dataMode || dataMode);
1843
+ console.log('Tools file:', toolsFile);
1844
+ console.log('Tool count:', (data.tools || []).length);
1845
+ }
1846
+
1847
+ function parseToolInput(flags) {
1848
+ if (flags['input-file']) {
1849
+ const file = path.resolve(process.cwd(), String(flags['input-file']));
1850
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1851
+ }
1852
+ if (flags.input) {
1853
+ return JSON.parse(String(flags.input));
1854
+ }
1855
+ return {};
1856
+ }
1857
+
1858
+ async function toolCommand(flags) {
1859
+ const cwd = process.cwd();
1860
+ ensureLocalState(cwd);
1861
+ const { session, accountId } = resolveSessionContext(cwd, flags);
1862
+
1863
+ const toolName = flags.name || flags.tool;
1864
+ if (!toolName) {
1865
+ throw new Error('Missing --name <toolName>');
1866
+ }
1867
+
1868
+ // Signal whether --account-id was explicitly passed so the server can apply
1869
+ // the correct precedence: explicit flag > input.accountId > session default.
1870
+ const explicitFlagAccountId = Number(flags && flags['account-id']);
1871
+ const accountIdExplicit = Number.isFinite(explicitFlagAccountId) && explicitFlagAccountId > 0;
1872
+
1873
+ const input = parseToolInput(flags);
1874
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
1875
+ const branchName = flags.branch || currentBranch(cwd);
1876
+ const dataMode = resolveDataMode(flags, session);
1877
+ const callId = crypto.randomUUID();
1878
+ const api = buildAxios(baseUrl, session.token);
1879
+
1880
+ const response = await loggedPost(api, cwd, '/cli/tool', {
1881
+ token: session.token,
1882
+ accountId,
1883
+ accountIdExplicit,
1884
+ branchName,
1885
+ dataMode,
1886
+ name: String(toolName),
1887
+ input,
1888
+ callId
1889
+ }, { toolResponseFile: true, toolCallId: callId });
1890
+
1891
+ const data = response.data;
1892
+ if (!data.success) {
1893
+ throw new Error(data.message || 'Tool execution failed');
1894
+ }
1895
+
1896
+ console.log('Tool call succeeded.');
1897
+ console.log('Call ID:', callId);
1898
+ console.log('Data mode:', dataMode);
1899
+ console.log('Session log:', sessionJsonlFile(cwd));
1900
+ console.log('Tool response file:', response.responseFile);
1901
+ }
1902
+
1903
+ async function dataModeCommand(flags, subcommand) {
1904
+ const cwd = process.cwd();
1905
+ // Explicit --account-id wins over the repo's account-info.json.
1906
+ let accountId = null;
1907
+ const fromFlag = Number(flags && flags['account-id']);
1908
+ if (Number.isFinite(fromFlag) && fromFlag > 0) accountId = fromFlag;
1909
+ if (!accountId) {
1910
+ try { accountId = loadAccountId(cwd); } catch (_) { /* not in repo */ }
1911
+ }
1912
+ const requestedBaseUrl = flags && flags['base-url'] ? normalizeBaseUrl(flags['base-url']) : null;
1913
+ const session = readSession({
1914
+ accountId,
1915
+ baseUrl: requestedBaseUrl
1916
+ }, {
1917
+ strictBaseUrl: Boolean(requestedBaseUrl)
1918
+ }) || {};
1919
+ if (!accountId && session.accountId) accountId = session.accountId;
1920
+
1921
+ const nextMode = flags.mode || flags.value || flags._[2];
1922
+ if (subcommand === 'set') {
1923
+ if (!nextMode) {
1924
+ throw new Error('Missing mode. Use: remits-cli data-mode set test|prod');
1925
+ }
1926
+ if (!accountId) {
1927
+ throw new Error('Unable to resolve account. Run from an account repo or pass --account-id.');
1928
+ }
1929
+ const dataMode = normalizeDataMode(nextMode);
1930
+ writeSession(accountId, {
1931
+ ...session,
1932
+ dataMode,
1933
+ updatedAt: new Date().toISOString()
1934
+ }, {
1935
+ baseUrl: requestedBaseUrl || session.baseUrl || DEFAULT_BASE_URL,
1936
+ dataMode
1937
+ });
1938
+ console.log('Data mode set to:', dataMode, 'for account', accountId, 'at', normalizeBaseUrl(requestedBaseUrl || session.baseUrl || DEFAULT_BASE_URL));
1939
+ return;
1940
+ }
1941
+
1942
+ const currentMode = resolveDataMode(flags, session);
1943
+ console.log(JSON.stringify({ dataMode: currentMode, accountId: accountId || undefined }, null, 2));
1944
+ }
1945
+
1946
+ // --- Session migration: convert old session.json to sessions.json ---
1947
+ function migrateSessionIfNeeded() {
1948
+ const oldFile = path.join(SESSION_DIR, 'session.json');
1949
+ if (!fs.existsSync(oldFile) || fs.existsSync(SESSIONS_FILE)) {
1950
+ return;
1951
+ }
1952
+ try {
1953
+ const old = JSON.parse(fs.readFileSync(oldFile, 'utf8'));
1954
+ if (old && old.accountId) {
1955
+ writeSession(old.accountId, old, {
1956
+ baseUrl: old.baseUrl || DEFAULT_BASE_URL,
1957
+ dataMode: old.dataMode || DEFAULT_DATA_MODE
1958
+ });
1959
+ console.log('Migrated session.json to sessions.json for account', old.accountId);
1960
+ }
1961
+ fs.renameSync(oldFile, oldFile + '.bak');
1962
+ } catch (_) {
1963
+ // Migration is best-effort
1964
+ }
1965
+ }
1966
+
1967
+ // --- Config command ---
1968
+ async function configCommand(flags, subcommand) {
1969
+ const config = readConfig();
1970
+
1971
+ if (subcommand === 'set') {
1972
+ if (flags.agent) {
1973
+ const agent = String(flags.agent).toLowerCase();
1974
+ if (!SUPPORTED_AGENTS.includes(agent)) {
1975
+ throw new Error('Unsupported agent: ' + agent + '. Supported: ' + SUPPORTED_AGENTS.join(', '));
1976
+ }
1977
+ config.agent = agent;
1978
+ }
1979
+ config.updatedAt = new Date().toISOString();
1980
+ writeConfig(config);
1981
+ console.log('Configuration updated:', JSON.stringify(config, null, 2));
1982
+ return;
1983
+ }
1984
+
1985
+ // Default: show current config
1986
+ console.log(JSON.stringify({
1987
+ agent: config.agent || DEFAULT_AGENT,
1988
+ supportedAgents: SUPPORTED_AGENTS,
1989
+ ...config
1990
+ }, null, 2));
1991
+ }
1992
+
1993
+ // --- Sessions list command ---
1994
+ async function sessionsCommand(flags, subcommand) {
1995
+ const sessions = readAllSessions();
1996
+ const entries = listSessionsFromStore(sessions);
1997
+
1998
+ if (subcommand === 'list' || !subcommand) {
1999
+ if (!entries.length) {
2000
+ console.log('No active sessions. Run: remits-cli auth');
2001
+ return;
2002
+ }
2003
+ console.log('Active sessions:');
2004
+ for (const s of entries) {
2005
+ const user = s.user ? (s.user.name || s.user.email || s.user.id) : 'unknown';
2006
+ console.log(' Account', s.accountId + ':', user, '(' + s.dataMode + ')', '-', s.baseUrl, '-', s.updatedAt);
2007
+ }
2008
+ return;
2009
+ }
2010
+
2011
+ if (subcommand === 'remove') {
2012
+ const id = flags['account-id'] || flags._[2];
2013
+ if (!id) {
2014
+ throw new Error('Missing account id. Usage: remits-cli sessions remove --account-id <ID>');
2015
+ }
2016
+ const requestedBaseUrl = flags['base-url'] ? normalizeBaseUrl(flags['base-url']) : null;
2017
+ const requestedDataMode = hasExplicitDataModeFlag(flags) ? resolveDataMode(flags, null) : null;
2018
+ let removed = 0;
2019
+ for (const [key, session] of Object.entries(sessions)) {
2020
+ if (Number(session.accountId) !== Number(id)) {
2021
+ continue;
2022
+ }
2023
+ if (requestedBaseUrl && normalizeBaseUrl(session.baseUrl) !== requestedBaseUrl) {
2024
+ continue;
2025
+ }
2026
+ if (requestedDataMode && normalizeDataMode(session.dataMode) !== requestedDataMode) {
2027
+ continue;
2028
+ }
2029
+ delete sessions[key];
2030
+ removed += 1;
2031
+ }
2032
+ writeAllSessions(sessions);
2033
+ console.log('Removed', removed, 'session(s) for account', id);
2034
+ return;
2035
+ }
2036
+
2037
+ throw new Error('Unknown sessions subcommand: ' + subcommand + '. Use: list, remove');
2038
+ }
2039
+
2040
+ // --- Listener PID management ---
2041
+ function isListenerRunning() {
2042
+ if (!fs.existsSync(LISTENER_PID_FILE)) {
2043
+ return false;
2044
+ }
2045
+ try {
2046
+ const pid = parseInt(fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim(), 10);
2047
+ if (!Number.isFinite(pid)) return false;
2048
+ process.kill(pid, 0); // Signal 0 checks if process exists
2049
+ return true;
2050
+ } catch (_) {
2051
+ // Process doesn't exist, clean up stale PID file
2052
+ try { fs.unlinkSync(LISTENER_PID_FILE); } catch (_) {}
2053
+ return false;
2054
+ }
2055
+ }
2056
+
2057
+ function writeListenerPid() {
2058
+ ensureSessionDir();
2059
+ atomicWriteFile(LISTENER_PID_FILE, String(process.pid));
2060
+ }
2061
+
2062
+ function clearListenerPid() {
2063
+ try { fs.unlinkSync(LISTENER_PID_FILE); } catch (_) {}
2064
+ }
2065
+
2066
+ function readServiceState() {
2067
+ if (!fs.existsSync(SERVICE_STATE_FILE)) {
2068
+ return {};
2069
+ }
2070
+ try {
2071
+ return JSON.parse(fs.readFileSync(SERVICE_STATE_FILE, 'utf8'));
2072
+ } catch (_) {
2073
+ return {};
2074
+ }
2075
+ }
2076
+
2077
+ function writeServiceState(data) {
2078
+ ensureSessionDir();
2079
+ atomicWriteFile(SERVICE_STATE_FILE, JSON.stringify(data, null, 2));
2080
+ }
2081
+
2082
+ function clearServiceState() {
2083
+ try { fs.unlinkSync(SERVICE_STATE_FILE); } catch (_) {}
2084
+ }
2085
+
2086
+ function readWebsocketState() {
2087
+ if (!fs.existsSync(WEBSOCKET_STATE_FILE)) {
2088
+ return {
2089
+ processedMessageIds: [],
2090
+ lastMessageTimestampByScope: {}
2091
+ };
2092
+ }
2093
+ try {
2094
+ const data = JSON.parse(fs.readFileSync(WEBSOCKET_STATE_FILE, 'utf8'));
2095
+ return {
2096
+ processedMessageIds: Array.isArray(data.processedMessageIds) ? data.processedMessageIds : [],
2097
+ lastMessageTimestampByScope: data.lastMessageTimestampByScope && typeof data.lastMessageTimestampByScope === 'object'
2098
+ ? data.lastMessageTimestampByScope
2099
+ : {}
2100
+ };
2101
+ } catch (_) {
2102
+ return {
2103
+ processedMessageIds: [],
2104
+ lastMessageTimestampByScope: {}
2105
+ };
2106
+ }
2107
+ }
2108
+
2109
+ function writeWebsocketState(data) {
2110
+ ensureSessionDir();
2111
+ atomicWriteFile(WEBSOCKET_STATE_FILE, JSON.stringify(data, null, 2));
2112
+ }
2113
+
2114
+ function websocketScopeKey(baseUrl, userId) {
2115
+ return String(baseUrl || '') + '|' + String(userId || '');
2116
+ }
2117
+
2118
+ const websocketStateStore = readWebsocketState();
2119
+ const processedMessageIds = new Set(websocketStateStore.processedMessageIds || []);
2120
+ let processedMessageIdOrder = Array.isArray(websocketStateStore.processedMessageIds)
2121
+ ? websocketStateStore.processedMessageIds.slice(-MAX_TRACKED_MESSAGE_IDS)
2122
+ : [];
2123
+ const lastMessageTimestampByScope = {
2124
+ ...(websocketStateStore.lastMessageTimestampByScope || {})
2125
+ };
2126
+
2127
+ function persistWebsocketState() {
2128
+ writeWebsocketState({
2129
+ processedMessageIds: processedMessageIdOrder.slice(-MAX_TRACKED_MESSAGE_IDS),
2130
+ lastMessageTimestampByScope
2131
+ });
2132
+ }
2133
+
2134
+ function rememberProcessedMessageId(messageId) {
2135
+ if (!messageId) return;
2136
+ const normalized = String(messageId);
2137
+ if (processedMessageIds.has(normalized)) {
2138
+ return;
2139
+ }
2140
+ processedMessageIds.add(normalized);
2141
+ processedMessageIdOrder.push(normalized);
2142
+ if (processedMessageIdOrder.length > MAX_TRACKED_MESSAGE_IDS) {
2143
+ const removed = processedMessageIdOrder.splice(0, processedMessageIdOrder.length - MAX_TRACKED_MESSAGE_IDS);
2144
+ for (const oldId of removed) {
2145
+ processedMessageIds.delete(oldId);
2146
+ }
2147
+ }
2148
+ persistWebsocketState();
2149
+ }
2150
+
2151
+ function hasProcessedMessageId(messageId) {
2152
+ if (!messageId) return false;
2153
+ return processedMessageIds.has(String(messageId));
2154
+ }
2155
+
2156
+ function updateLastMessageTimestamp(baseUrl, userIds, timestamp) {
2157
+ const numericTimestamp = Number(timestamp);
2158
+ if (!Number.isFinite(numericTimestamp) || numericTimestamp <= 0) {
2159
+ return;
2160
+ }
2161
+ for (const userId of userIds || []) {
2162
+ if (!userId) continue;
2163
+ const key = websocketScopeKey(baseUrl, userId);
2164
+ const current = Number(lastMessageTimestampByScope[key] || 0);
2165
+ if (!current || numericTimestamp > current) {
2166
+ lastMessageTimestampByScope[key] = numericTimestamp;
2167
+ }
2168
+ }
2169
+ persistWebsocketState();
2170
+ }
2171
+
2172
+ function lastMessageTimestampForScope(baseUrl, userId) {
2173
+ return Number(lastMessageTimestampByScope[websocketScopeKey(baseUrl, userId)] || 0);
2174
+ }
2175
+
2176
+ function summarizeWebsocketPayload(payload) {
2177
+ const safePayload = sanitizeForLog(payload);
2178
+ if (safePayload && typeof safePayload === 'object' && !Array.isArray(safePayload)) {
2179
+ return safePayload;
2180
+ }
2181
+ return { value: safePayload };
2182
+ }
2183
+
2184
+ function recordWebsocketMessage(baseUrl, topic, data, options = {}) {
2185
+ if (!runtimeState.websocket[baseUrl]) {
2186
+ runtimeState.websocket[baseUrl] = {
2187
+ status: 'connected',
2188
+ connectedAt: null,
2189
+ disconnectedAt: null,
2190
+ lastMessageAt: null,
2191
+ lastError: null,
2192
+ topics: {},
2193
+ recentMessages: []
2194
+ };
2195
+ }
2196
+ if (!Array.isArray(runtimeState.websocket[baseUrl].recentMessages)) {
2197
+ runtimeState.websocket[baseUrl].recentMessages = [];
2198
+ }
2199
+
2200
+ const message = data && typeof data === 'object' ? data : {};
2201
+ const entry = {
2202
+ receivedAt: new Date().toISOString(),
2203
+ source: options.source || 'live',
2204
+ topic: topic || null,
2205
+ messageId: message.messageId || null,
2206
+ timestamp: message.timestamp || null,
2207
+ type: message.type || null,
2208
+ duplicate: Boolean(options.duplicate),
2209
+ accountIds: Array.isArray(options.accountIds) ? options.accountIds : [],
2210
+ payload: summarizeWebsocketPayload(message.payload || {})
2211
+ };
2212
+
2213
+ runtimeState.websocket[baseUrl].recentMessages.unshift(entry);
2214
+ if (runtimeState.websocket[baseUrl].recentMessages.length > MAX_RECENT_WEBSOCKET_MESSAGES) {
2215
+ runtimeState.websocket[baseUrl].recentMessages.length = MAX_RECENT_WEBSOCKET_MESSAGES;
2216
+ }
2217
+ }
2218
+
2219
+ async function waitForDashboardUrl(timeoutMs = 5000) {
2220
+ const deadline = Date.now() + timeoutMs;
2221
+ while (Date.now() < deadline) {
2222
+ const state = readServiceState();
2223
+ if (state && state.dashboardUrl) {
2224
+ return state.dashboardUrl;
2225
+ }
2226
+ await new Promise((resolve) => setTimeout(resolve, 100));
2227
+ }
2228
+ return null;
2229
+ }
2230
+
2231
+ async function waitForServiceStart(expectedPid, timeoutMs = 5000) {
2232
+ const deadline = Date.now() + timeoutMs;
2233
+ while (Date.now() < deadline) {
2234
+ const state = readServiceState();
2235
+ if (state && Number(state.pid) === Number(expectedPid) && state.dashboardUrl) {
2236
+ try {
2237
+ process.kill(Number(expectedPid), 0);
2238
+ return state.dashboardUrl;
2239
+ } catch (_) {
2240
+ return null;
2241
+ }
2242
+ }
2243
+ await new Promise((resolve) => setTimeout(resolve, 100));
2244
+ }
2245
+ return null;
2246
+ }
2247
+
2248
+ async function autoStartServiceIfNeeded() {
2249
+ if (isListenerRunning()) {
2250
+ return readServiceState().dashboardUrl || null;
2251
+ }
2252
+ const sessions = readAllSessions();
2253
+ const entries = listenerSessionEntriesFromStore(sessions);
2254
+ if (!entries.length) {
2255
+ return null;
2256
+ }
2257
+ clearServiceState();
2258
+ const { spawn } = require('child_process');
2259
+ const child = spawn(process.execPath, [__filename, 'start', '--foreground', 'true'], {
2260
+ detached: true,
2261
+ stdio: 'ignore',
2262
+ env: { ...process.env }
2263
+ });
2264
+ child.unref();
2265
+ return waitForServiceStart(child.pid, 3000);
2266
+ }
2267
+
2268
+ let managedClients = [];
2269
+ let dashboardServer = null;
2270
+ let runtimeState = {
2271
+ service: {
2272
+ startedAt: null,
2273
+ pid: null,
2274
+ dashboardPort: null,
2275
+ dashboardUrl: null
2276
+ },
2277
+ discovery: {
2278
+ scannedAt: null,
2279
+ roots: [],
2280
+ matches: 0,
2281
+ repoCount: 0
2282
+ },
2283
+ websocket: {},
2284
+ lastAction: null
2285
+ };
2286
+
2287
+ function listenerSessionEntriesFromStore(store) {
2288
+ return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
2289
+ }
2290
+
2291
+ function listenerSessionSignature(entries) {
2292
+ return entries
2293
+ .map((session) => stableStringify({
2294
+ accountId: Number(session.accountId),
2295
+ baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
2296
+ websocketTopic: String(session.websocketTopic || ''),
2297
+ userId: session.user && session.user.id != null ? String(session.user.id) : '',
2298
+ tokenHash: sha256(String(session.token || ''))
2299
+ }))
2300
+ .sort()
2301
+ .join('|');
2302
+ }
2303
+
2304
+ function groupSessionsByBaseUrl(entries) {
2305
+ const grouped = {};
2306
+ for (const session of entries || []) {
2307
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
2308
+ if (!grouped[baseUrl]) {
2309
+ grouped[baseUrl] = [];
2310
+ }
2311
+ grouped[baseUrl].push(session);
2312
+ }
2313
+ return grouped;
2314
+ }
2315
+
2316
+ function ensureRuntimeWebsocketEntry(baseUrl) {
2317
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
2318
+ if (!runtimeState.websocket[normalizedBaseUrl]) {
2319
+ runtimeState.websocket[normalizedBaseUrl] = {
2320
+ status: 'connecting',
2321
+ connectedAt: null,
2322
+ disconnectedAt: null,
2323
+ lastMessageAt: null,
2324
+ lastError: null,
2325
+ topics: {},
2326
+ recentMessages: []
2327
+ };
2328
+ }
2329
+ if (!runtimeState.websocket[normalizedBaseUrl].topics || typeof runtimeState.websocket[normalizedBaseUrl].topics !== 'object') {
2330
+ runtimeState.websocket[normalizedBaseUrl].topics = {};
2331
+ }
2332
+ if (!Array.isArray(runtimeState.websocket[normalizedBaseUrl].recentMessages)) {
2333
+ runtimeState.websocket[normalizedBaseUrl].recentMessages = [];
2334
+ }
2335
+ return runtimeState.websocket[normalizedBaseUrl];
2336
+ }
2337
+
2338
+ function websocketReadyStateName(readyState) {
2339
+ switch (readyState) {
2340
+ case WebSocket.CONNECTING:
2341
+ return 'CONNECTING';
2342
+ case WebSocket.OPEN:
2343
+ return 'OPEN';
2344
+ case WebSocket.CLOSING:
2345
+ return 'CLOSING';
2346
+ case WebSocket.CLOSED:
2347
+ return 'CLOSED';
2348
+ default:
2349
+ return 'UNKNOWN';
2350
+ }
2351
+ }
2352
+
2353
+ function describeWebsocketCloseEvent(event) {
2354
+ if (!event || typeof event !== 'object') {
2355
+ return 'WebSocket closed';
2356
+ }
2357
+ const parts = [];
2358
+ if (event.code != null) {
2359
+ parts.push('code=' + String(event.code));
2360
+ }
2361
+ if (event.reason) {
2362
+ parts.push('reason=' + String(event.reason));
2363
+ }
2364
+ if (event.wasClean != null) {
2365
+ parts.push('clean=' + String(Boolean(event.wasClean)));
2366
+ }
2367
+ return parts.length ? parts.join(', ') : 'WebSocket closed';
2368
+ }
2369
+
2370
+ function describeWebsocketErrorEvent(event) {
2371
+ if (!event) {
2372
+ return 'WebSocket error';
2373
+ }
2374
+ if (event.message) {
2375
+ return String(event.message);
2376
+ }
2377
+ if (event.error && event.error.message) {
2378
+ return String(event.error.message);
2379
+ }
2380
+ if (event.type) {
2381
+ return 'WebSocket ' + String(event.type);
2382
+ }
2383
+ return 'WebSocket error';
2384
+ }
2385
+
2386
+ function updateRuntimeServiceState() {
2387
+ writeServiceState({
2388
+ pid: process.pid,
2389
+ startedAt: runtimeState.service.startedAt,
2390
+ dashboardPort: runtimeState.service.dashboardPort,
2391
+ dashboardUrl: runtimeState.service.dashboardUrl,
2392
+ discovery: runtimeState.discovery,
2393
+ websocket: runtimeState.websocket,
2394
+ updatedAt: new Date().toISOString()
2395
+ });
2396
+ }
2397
+
2398
+ function recordDiscoverySummary(summary) {
2399
+ runtimeState.discovery = {
2400
+ scannedAt: summary && summary.scannedAt ? summary.scannedAt : new Date().toISOString(),
2401
+ roots: summary && summary.roots ? summary.roots : [],
2402
+ matches: summary && Number.isFinite(summary.matches) ? summary.matches : 0,
2403
+ repoCount: summary && Number.isFinite(summary.repoCount) ? summary.repoCount : countAccountRepos()
2404
+ };
2405
+ updateRuntimeServiceState();
2406
+ }
2407
+
2408
+ function fileStat(file) {
2409
+ try {
2410
+ return fs.statSync(file);
2411
+ } catch (_) {
2412
+ return null;
2413
+ }
2414
+ }
2415
+
2416
+ function truncateText(value, maxChars = 20000) {
2417
+ const text = String(value || '');
2418
+ if (text.length <= maxChars) {
2419
+ return text;
2420
+ }
2421
+ return text.slice(0, maxChars) + '\n...[truncated ' + (text.length - maxChars) + ' chars]';
2422
+ }
2423
+
2424
+ function readJsonFilePretty(file) {
2425
+ if (!fs.existsSync(file)) {
2426
+ return null;
2427
+ }
2428
+ try {
2429
+ return JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8')), null, 2);
2430
+ } catch (_) {
2431
+ return truncateText(fs.readFileSync(file, 'utf8'));
2432
+ }
2433
+ }
2434
+
2435
+ function tailTextFile(file, maxLines = 80) {
2436
+ if (!fs.existsSync(file)) {
2437
+ return null;
2438
+ }
2439
+ const text = fs.readFileSync(file, 'utf8');
2440
+ const lines = text.split('\n');
2441
+ return truncateText(lines.slice(-maxLines).join('\n'));
2442
+ }
2443
+
2444
+ function readJsonLinesFile(file, maxEntries = 80) {
2445
+ if (!file || !fs.existsSync(file)) {
2446
+ return [];
2447
+ }
2448
+ try {
2449
+ const lines = fs.readFileSync(file, 'utf8')
2450
+ .split('\n')
2451
+ .map((line) => line.trim())
2452
+ .filter(Boolean)
2453
+ .slice(-maxEntries);
2454
+ return lines.map((line) => {
2455
+ try {
2456
+ return JSON.parse(line);
2457
+ } catch (_) {
2458
+ return null;
2459
+ }
2460
+ }).filter(Boolean);
2461
+ } catch (_) {
2462
+ return [];
2463
+ }
2464
+ }
2465
+
2466
+ function slugify(value) {
2467
+ return String(value || 'issue')
2468
+ .toLowerCase()
2469
+ .replace(/[^a-z0-9]+/g, '-')
2470
+ .replace(/^-+|-+$/g, '')
2471
+ .slice(0, 60) || 'issue';
2472
+ }
2473
+
2474
+ function detectIssueSignals(repoEntries, globalFiles) {
2475
+ const issues = [];
2476
+ const tmuxLog = (globalFiles || []).find((file) => file.label === 'tmux-activity.log (tail)');
2477
+ if (tmuxLog && tmuxLog.content) {
2478
+ const errorLines = tmuxLog.content
2479
+ .split('\n')
2480
+ .filter((line) => /\[error\]/i.test(line))
2481
+ .slice(-8);
2482
+ if (errorLines.length) {
2483
+ issues.push({
2484
+ id: 'tmux-activity-errors',
2485
+ severity: 'warn',
2486
+ scope: 'global',
2487
+ title: 'Recent service or tmux errors detected',
2488
+ summary: 'The global remits-cli activity log contains recent error-level events.',
2489
+ evidence: errorLines,
2490
+ recommendation: 'Review the activity tail and export an escalation bundle if this reflects unexpected platform or dispatch behavior.'
2491
+ });
2492
+ }
2493
+ }
2494
+
2495
+ for (const repo of repoEntries || []) {
2496
+ const sessionLogFile = (repo.files || []).find((file) => file.label === '.remits-cli/session log (tail)');
2497
+ if (!sessionLogFile || !sessionLogFile.path || !fs.existsSync(sessionLogFile.path)) {
2498
+ continue;
2499
+ }
2500
+ const entries = readJsonLinesFile(sessionLogFile.path, 80);
2501
+ const problemEntries = entries.filter((entry) => {
2502
+ const responseText = JSON.stringify(entry.response || {});
2503
+ const errorText = String(entry.error || '');
2504
+ return entry.status === 'error' ||
2505
+ /500|Internal Server Error/i.test(responseText) ||
2506
+ /500|Internal Server Error/i.test(errorText);
2507
+ }).slice(-6);
2508
+
2509
+ if (problemEntries.length) {
2510
+ const evidence = problemEntries.map((entry) => {
2511
+ return [
2512
+ entry.ts || 'unknown-ts',
2513
+ entry.endpoint || 'unknown-endpoint',
2514
+ entry.status || 'unknown-status',
2515
+ entry.error || JSON.stringify(entry.response || {})
2516
+ ].join(' | ');
2517
+ });
2518
+ issues.push({
2519
+ id: 'repo-' + repo.accountId + '-session-errors',
2520
+ severity: evidence.some((line) => /500|Internal Server Error/i.test(line)) ? 'bad' : 'warn',
2521
+ scope: 'repo',
2522
+ repoAccountId: repo.accountId,
2523
+ repoName: repo.name || null,
2524
+ title: 'Repo session log shows recent remits-cli errors',
2525
+ summary: 'Recent `/cli/*` activity for this repo includes failed requests or platform error responses.',
2526
+ evidence,
2527
+ recommendation: 'Inspect the repo session log and exported artifacts before escalating to a Remits system admin.'
2528
+ });
2529
+ }
2530
+ }
2531
+
2532
+ return issues;
2533
+ }
2534
+
2535
+ function copyFileIfExists(source, destination) {
2536
+ if (!source || !fs.existsSync(source)) {
2537
+ return false;
2538
+ }
2539
+ ensureDir(path.dirname(destination));
2540
+ fs.copyFileSync(source, destination);
2541
+ return true;
2542
+ }
2543
+
2544
+ function exportIssueBundle(snapshot) {
2545
+ ensureDir(ISSUES_DIR);
2546
+ const primaryIssue = (snapshot.issues && snapshot.issues[0]) || null;
2547
+ const primaryAccountId = primaryIssue && primaryIssue.repoAccountId ? primaryIssue.repoAccountId : 'global';
2548
+ const slug = slugify(primaryIssue ? primaryIssue.title : 'remits-cli-issue');
2549
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
2550
+ const bundleDir = path.join(ISSUES_DIR, timestamp + '-account-' + primaryAccountId + '-' + slug);
2551
+ ensureDir(bundleDir);
2552
+
2553
+ const summaryLines = [
2554
+ '# Remits CLI Issue Export',
2555
+ '',
2556
+ 'Generated: ' + new Date().toISOString(),
2557
+ 'Bundle Directory: ' + bundleDir,
2558
+ '',
2559
+ '## Why This Was Exported',
2560
+ '',
2561
+ 'The control center detected issue signals that may indicate a Remits platform bug, 500 response, websocket/dispatch failure, or another unexpected system-level problem.',
2562
+ '',
2563
+ '## Operator Notes',
2564
+ '',
2565
+ '- What were you trying to do?',
2566
+ '- Why were you trying to do it?',
2567
+ '- What did you expect to happen?',
2568
+ '- What actually happened?',
2569
+ '',
2570
+ '## Detected Issues',
2571
+ ''
2572
+ ];
2573
+
2574
+ if (snapshot.issues && snapshot.issues.length) {
2575
+ snapshot.issues.forEach((issue, index) => {
2576
+ summaryLines.push((index + 1) + '. ' + issue.title);
2577
+ summaryLines.push(' Severity: ' + issue.severity);
2578
+ summaryLines.push(' Scope: ' + issue.scope + (issue.repoAccountId ? ' account ' + issue.repoAccountId : ''));
2579
+ summaryLines.push(' Summary: ' + issue.summary);
2580
+ summaryLines.push(' Recommendation: ' + issue.recommendation);
2581
+ if (issue.evidence && issue.evidence.length) {
2582
+ summaryLines.push(' Evidence:');
2583
+ issue.evidence.forEach((line) => summaryLines.push(' - ' + line));
2584
+ }
2585
+ summaryLines.push('');
2586
+ });
2587
+ } else {
2588
+ summaryLines.push('No automatic issue signals were detected at export time, but the bundle was created manually for further review.');
2589
+ summaryLines.push('');
2590
+ }
2591
+
2592
+ summaryLines.push('## Included Files');
2593
+ summaryLines.push('');
2594
+ summaryLines.push('- `context.json`');
2595
+ summaryLines.push('- `snapshot.json`');
2596
+ summaryLines.push('- `global/` copies of important remits-cli state files');
2597
+ summaryLines.push('- `repos/` copies of indexed repo remits-cli files when present');
2598
+ summaryLines.push('');
2599
+ summaryLines.push('## Guidance For Remits System Admin');
2600
+ summaryLines.push('');
2601
+ summaryLines.push('Use `snapshot.json` and `context.json` as the structured overview, then inspect the copied session/activity files to correlate the exact failing command or dispatch behavior.');
2602
+ summaryLines.push('');
2603
+
2604
+ fs.writeFileSync(path.join(bundleDir, 'summary.md'), summaryLines.join('\n'));
2605
+ fs.writeFileSync(path.join(bundleDir, 'context.json'), JSON.stringify({
2606
+ exportedAt: new Date().toISOString(),
2607
+ cwd: process.cwd(),
2608
+ service: snapshot.service,
2609
+ discovery: snapshot.discovery,
2610
+ issueCount: (snapshot.issues || []).length,
2611
+ tmux: {
2612
+ available: snapshot.tmux && snapshot.tmux.available,
2613
+ sessionExists: snapshot.tmux && snapshot.tmux.sessionExists,
2614
+ sessionName: snapshot.tmux && snapshot.tmux.sessionName,
2615
+ livePaneCount: snapshot.tmux && snapshot.tmux.livePanes ? snapshot.tmux.livePanes.length : 0
2616
+ },
2617
+ repos: (snapshot.repos || []).map((repo) => ({
2618
+ accountId: repo.accountId,
2619
+ name: repo.name,
2620
+ type: repo.type,
2621
+ directory: repo.directory
2622
+ }))
2623
+ }, null, 2));
2624
+ fs.writeFileSync(path.join(bundleDir, 'snapshot.json'), JSON.stringify(snapshot, null, 2));
2625
+
2626
+ const globalDir = path.join(bundleDir, 'global');
2627
+ ensureDir(globalDir);
2628
+ copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
2629
+ copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
2630
+ copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
2631
+ copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
2632
+ copyFileIfExists(DISPATCH_STATE_FILE, path.join(globalDir, 'dispatch-panes.json'));
2633
+ copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
2634
+
2635
+ const reposDir = path.join(bundleDir, 'repos');
2636
+ ensureDir(reposDir);
2637
+ for (const repo of snapshot.repos || []) {
2638
+ const repoDir = path.join(reposDir, String(repo.accountId));
2639
+ ensureDir(repoDir);
2640
+ for (const file of repo.files || []) {
2641
+ if (!file.path) continue;
2642
+ const ext = path.extname(file.path || '') || '.txt';
2643
+ copyFileIfExists(file.path, path.join(repoDir, slugify(file.label) + ext));
2644
+ }
2645
+ }
2646
+
2647
+ return bundleDir;
2648
+ }
2649
+
2650
+ function escapeHtml(value) {
2651
+ return String(value == null ? '' : value)
2652
+ .replace(/&/g, '&amp;')
2653
+ .replace(/</g, '&lt;')
2654
+ .replace(/>/g, '&gt;')
2655
+ .replace(/"/g, '&quot;')
2656
+ .replace(/'/g, '&#39;');
2657
+ }
2658
+
2659
+ function renderActionButton(action, label) {
2660
+ return '<form method="POST" action="/action" style="display:inline-block;margin:0 8px 8px 0;">' +
2661
+ '<input type="hidden" name="action" value="' + escapeHtml(action) + '">' +
2662
+ '<button type="submit" style="padding:8px 12px;border:1px solid #222;background:#fff7e6;cursor:pointer;">' + escapeHtml(label) + '</button>' +
2663
+ '</form>';
2664
+ }
2665
+
2666
+ function buildGlobalFileSnapshot() {
2667
+ const files = [
2668
+ { label: 'sessions.json', path: SESSIONS_FILE, mode: 'json' },
2669
+ { label: 'config.json', path: CONFIG_FILE, mode: 'json' },
2670
+ { label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
2671
+ { label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
2672
+ { label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
2673
+ { label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
2674
+ { label: 'tmux-activity.log (tail)', path: TMUX_ACTIVITY_LOG_FILE, mode: 'tail' }
2675
+ ];
2676
+
2677
+ return files.map((file) => {
2678
+ const stat = fileStat(file.path);
2679
+ return {
2680
+ ...file,
2681
+ exists: Boolean(stat),
2682
+ size: stat ? stat.size : 0,
2683
+ updatedAt: stat ? stat.mtime.toISOString() : null,
2684
+ content: file.mode === 'tail' ? tailTextFile(file.path) : readJsonFilePretty(file.path)
2685
+ };
2686
+ });
2687
+ }
2688
+
2689
+ function buildRepoSnapshot(entry) {
2690
+ const directory = entry.directory;
2691
+ const localPaths = localStatePaths(directory);
2692
+ const currentSessionName = fs.existsSync(localPaths.currentSessionFile)
2693
+ ? fs.readFileSync(localPaths.currentSessionFile, 'utf8').trim()
2694
+ : '';
2695
+ const currentSessionLog = currentSessionName
2696
+ ? path.join(localPaths.sessionsDir, currentSessionName + '.jsonl')
2697
+ : null;
2698
+ const repoFiles = [
2699
+ { label: 'account-info.json', path: entry.accountInfoPath || path.join(directory, 'account-info.json'), mode: 'json' },
2700
+ { label: '.remits-cli/current-session.txt', path: localPaths.currentSessionFile, mode: 'text' },
2701
+ { label: '.remits-cli/tools/tools.json', path: path.join(localPaths.toolsDir, 'tools.json'), mode: 'json' },
2702
+ { label: '.remits-cli/session log (tail)', path: currentSessionLog, mode: 'tail' }
2703
+ ];
2704
+
2705
+ return {
2706
+ ...entry,
2707
+ exists: fs.existsSync(directory),
2708
+ files: repoFiles.map((file) => {
2709
+ const stat = file.path ? fileStat(file.path) : null;
2710
+ let content = null;
2711
+ if (file.path) {
2712
+ if (file.mode === 'json') content = readJsonFilePretty(file.path);
2713
+ if (file.mode === 'tail') content = tailTextFile(file.path, 40);
2714
+ if (file.mode === 'text' && fs.existsSync(file.path)) content = truncateText(fs.readFileSync(file.path, 'utf8'));
2715
+ }
2716
+ return {
2717
+ ...file,
2718
+ exists: Boolean(stat),
2719
+ size: stat ? stat.size : 0,
2720
+ updatedAt: stat ? stat.mtime.toISOString() : null,
2721
+ content
2722
+ };
2723
+ })
2724
+ };
2725
+ }
2726
+
2727
+ function collectDashboardSnapshot() {
2728
+ const accountRepoIndex = readAccountRepoIndex();
2729
+ const repoEntries = listAccountRepoEntries(accountRepoIndex)
2730
+ .sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
2731
+ .map(buildRepoSnapshot);
2732
+ const platformRepo = getPlatformRepoEntry(accountRepoIndex);
2733
+ const globalFiles = buildGlobalFileSnapshot();
2734
+ const issues = detectIssueSignals(repoEntries, globalFiles);
2735
+
2736
+ const paneState = fs.existsSync(DISPATCH_STATE_FILE) ? readJsonFilePretty(DISPATCH_STATE_FILE) : null;
2737
+ const livePanes = [];
2738
+ try {
2739
+ if (isTmuxAvailable() && tmuxSessionExists()) {
2740
+ const output = execSync(
2741
+ '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}'),
2742
+ { stdio: ['ignore', 'pipe', 'ignore'] }
2743
+ ).toString();
2744
+ for (const line of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
2745
+ const parts = line.split('\t');
2746
+ livePanes.push({
2747
+ session: parts[0] || null,
2748
+ window: parts[1] || null,
2749
+ paneId: parts[2] || null,
2750
+ currentPath: parts[3] || null,
2751
+ dead: parts[4] === '1'
2752
+ });
2753
+ }
2754
+ }
2755
+ } catch (_) {
2756
+ // Dashboard should remain readable even if tmux inspection fails.
2757
+ }
2758
+
2759
+ return {
2760
+ generatedAt: new Date().toISOString(),
2761
+ service: {
2762
+ running: isListenerRunning(),
2763
+ pid: fs.existsSync(LISTENER_PID_FILE) ? fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim() : null,
2764
+ startedAt: runtimeState.service.startedAt,
2765
+ dashboardUrl: runtimeState.service.dashboardUrl
2766
+ },
2767
+ websocket: {
2768
+ connections: runtimeState.websocket
2769
+ },
2770
+ tmux: {
2771
+ available: isTmuxAvailable(),
2772
+ terminalLaunchSupported: canLaunchTerminalForTmux(),
2773
+ sessionName: TMUX_SESSION_NAME,
2774
+ sessionExists: tmuxSessionExists(),
2775
+ trackedPanes: paneState,
2776
+ livePanes
2777
+ },
2778
+ discovery: runtimeState.discovery,
2779
+ globalFiles,
2780
+ repos: repoEntries,
2781
+ platformRepo,
2782
+ issues,
2783
+ lastAction: runtimeState.lastAction
2784
+ };
2785
+ }
2786
+
2787
+ function renderDashboardHtml(snapshot) {
2788
+ const initialState = JSON.stringify(snapshot)
2789
+ .replace(/</g, '\\u003c')
2790
+ .replace(/>/g, '\\u003e')
2791
+ .replace(/&/g, '\\u0026');
2792
+ return '<!DOCTYPE html>' +
2793
+ '<html><head><meta charset="utf-8">' +
2794
+ '<title>Remits CLI Control Center</title>' +
2795
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
2796
+ '<style>' +
2797
+ ':root{--bg:#f5f1e8;--surface:#fffdf8;--surface-strong:#fff;--ink:#1f1c16;--muted:#6f685d;--line:#d9d0c0;--line-strong:#bbaa93;--green:#1f8f58;--green-soft:#e8f6ee;--yellow:#c38a16;--yellow-soft:#fff6db;--red:#c1493d;--red-soft:#fdeae7;--blue:#2456d3;--blue-soft:#eaf0ff;}' +
2798
+ '*{box-sizing:border-box;}body{margin:0;font-family:Menlo,Monaco,monospace;background:radial-gradient(circle at top left,#fffdf8 0,#f5f1e8 48%,#ece4d7 100%);color:var(--ink);}h1,h2,h3,h4,summary{font-family:Georgia,serif;}button{font:inherit;}a{color:var(--blue);}main{max-width:1440px;margin:0 auto;padding:24px;}header.hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:20px;padding:24px;border:1px solid var(--line);border-radius:22px;background:linear-gradient(135deg,#fffefb 0,#f8f1e3 100%);box-shadow:0 14px 40px rgba(72,53,22,.08);}header.hero p{margin:8px 0 0 0;max-width:760px;color:var(--muted);line-height:1.5;} .hero-meta{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;} .pill{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.75);font-size:12px;} .status-grid,.section-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;} .section-grid{margin-top:18px;} .status-card,.panel,.action-card{border:1px solid var(--line);border-radius:18px;background:var(--surface-strong);box-shadow:0 10px 28px rgba(72,53,22,.06);} .status-card{padding:18px;position:relative;overflow:hidden;} .status-card::after{content:"";position:absolute;inset:auto -40px -40px auto;width:120px;height:120px;border-radius:999px;background:rgba(0,0,0,.03);} .status-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;} .status-dot{width:14px;height:14px;border-radius:50%;display:inline-block;box-shadow:0 0 0 4px rgba(0,0,0,.04);} .ok .status-dot{background:var(--green);} .warn .status-dot{background:var(--yellow);} .bad .status-dot{background:var(--red);} .status-value{font-size:30px;line-height:1;font-family:Georgia,serif;} .status-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);} .status-sub{margin-top:10px;color:var(--muted);font-size:13px;line-height:1.45;} .status-card.ok{background:linear-gradient(180deg,var(--surface-strong),var(--green-soft));} .status-card.warn{background:linear-gradient(180deg,var(--surface-strong),var(--yellow-soft));} .status-card.bad{background:linear-gradient(180deg,var(--surface-strong),var(--red-soft));}' +
2799
+ '.action-card{padding:18px;margin:18px 0 0 0;background:linear-gradient(180deg,#fffdf9,#f7f1e6);} .action-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;} .action-btn{padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--surface-strong);cursor:pointer;transition:transform .12s ease,background .12s ease,border-color .12s ease;} .action-btn:hover{transform:translateY(-1px);background:#fff7ea;} .action-btn:disabled{opacity:.55;cursor:wait;transform:none;} .panel{padding:18px;} .panel h2{margin:0 0 12px 0;font-size:22px;} .muted{color:var(--muted);} .summary-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:8px;} .summary-item{padding:12px;border:1px solid var(--line);border-radius:14px;background:#fcfaf4;} .summary-item .k{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:6px;} .summary-item .v{font-size:18px;font-family:Georgia,serif;}' +
2800
+ '.accordion{display:grid;gap:14px;} details.group,details.item{border:1px solid var(--line);border-radius:16px;background:#fffdf9;overflow:hidden;} details.group[open],details.item[open]{border-color:var(--line-strong);box-shadow:0 10px 24px rgba(72,53,22,.05);} summary{list-style:none;cursor:pointer;padding:16px 18px;display:flex;align-items:center;justify-content:space-between;gap:16px;} summary::-webkit-details-marker{display:none;} .summary-main{display:flex;align-items:center;gap:12px;min-width:0;} .summary-text{display:flex;flex-direction:column;gap:4px;min-width:0;} .summary-title{font-size:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .summary-meta{font-size:12px;color:var(--muted);} .summary-caret{color:var(--muted);font-size:18px;} details[open] > summary .summary-caret{transform:rotate(90deg);} .group-body,.item-body{padding:0 18px 18px 18px;} .mini-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px;} .mini-card{padding:12px;border:1px solid var(--line);border-radius:12px;background:#fcfaf4;} .mini-card strong{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;} .code-block{white-space:pre-wrap;overflow:auto;max-height:320px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#f7f4ee;font-size:12px;line-height:1.45;} .code-block.log{max-height:420px;background:#1f1c16;color:#f4ead7;border-color:#3f382d;} .stack{display:grid;gap:12px;} .badge{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;border:1px solid var(--line);font-size:11px;text-transform:uppercase;letter-spacing:.08em;} .badge.ok{background:var(--green-soft);border-color:#b9dfc9;color:#175a39;} .badge.warn{background:var(--yellow-soft);border-color:#edd69c;color:#895c08;} .badge.bad{background:var(--red-soft);border-color:#efc1bb;color:#8b322a;} .repo-files{display:grid;gap:12px;} .empty{padding:18px;border:1px dashed var(--line-strong);border-radius:16px;background:#fcfaf4;color:var(--muted);} .toast{position:fixed;right:18px;bottom:18px;padding:12px 14px;border-radius:14px;border:1px solid var(--line-strong);background:#fffdf8;box-shadow:0 14px 24px rgba(72,53,22,.16);display:none;max-width:360px;} .toast.show{display:block;} @media (max-width:900px){main{padding:16px;}header.hero{flex-direction:column;} .hero-meta{justify-content:flex-start;}}' +
2801
+ '</style>' +
2802
+ '</head><body>' +
2803
+ '<main>' +
2804
+ '<header class="hero">' +
2805
+ '<div>' +
2806
+ '<h1 style="margin:0;">Remits CLI Control Center</h1>' +
2807
+ '<p>Human-friendly overview of remits-cli integration state. The dashboard refreshes in place, preserves scroll inside logs, and keeps verbose internals collapsed until you want them.</p>' +
2808
+ '</div>' +
2809
+ '<div class="hero-meta">' +
2810
+ '<span class="pill"><strong>Live</strong><span id="refresh-label">Updating every 5s</span></span>' +
2811
+ '<span class="pill"><strong>Generated</strong><span id="generated-at">n/a</span></span>' +
2812
+ '</div>' +
2813
+ '</header>' +
2814
+ '<section id="status-grid" class="status-grid"></section>' +
2815
+ '<section class="action-card">' +
2816
+ '<h2 style="margin:0;">Controls</h2>' +
2817
+ '<div class="muted">Use these actions to repair or refresh the local integration without leaving the dashboard, or export a consistent escalation bundle for a Remits system admin.</div>' +
2818
+ '<div class="action-row">' +
2819
+ '<button class="action-btn" data-action="export-issues">Export Issue Bundle</button>' +
2820
+ '<button class="action-btn" data-action="rescan-repos">Rescan Account Repos</button>' +
2821
+ '<button class="action-btn" data-action="reconnect-websockets">Reconnect Websockets</button>' +
2822
+ '<button class="action-btn" data-action="open-tmux">Open Tmux Terminal</button>' +
2823
+ '<button class="action-btn" data-action="start-tmux">Ensure Tmux Session</button>' +
2824
+ '<button class="action-btn" data-action="stop-tmux">Kill Tmux Session</button>' +
2825
+ '</div>' +
2826
+ '</section>' +
2827
+ '<div class="section-grid">' +
2828
+ '<section class="panel">' +
2829
+ '<h2>High-Level Summary</h2>' +
2830
+ '<div id="summary-grid" class="summary-list"></div>' +
2831
+ '</section>' +
2832
+ '<section class="panel">' +
2833
+ '<h2>Latest Action</h2>' +
2834
+ '<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
2835
+ '</section>' +
2836
+ '</div>' +
2837
+ '<div class="section-grid">' +
2838
+ '<section class="panel"><h2>Issues & Escalation</h2><div id="issues-section" class="accordion"></div></section>' +
2839
+ '</div>' +
2840
+ '<div class="section-grid">' +
2841
+ '<section class="panel"><h2>Websocket Connections</h2><div id="websocket-section" class="accordion"></div></section>' +
2842
+ '<section class="panel"><h2>Tmux</h2><div id="tmux-section" class="accordion"></div></section>' +
2843
+ '</div>' +
2844
+ '<div class="section-grid">' +
2845
+ '<section class="panel"><h2>Global Files</h2><div id="global-files" class="accordion"></div></section>' +
2846
+ '<section class="panel"><h2>Indexed Repos</h2><div id="repo-section" class="accordion"></div></section>' +
2847
+ '</div>' +
2848
+ '</main>' +
2849
+ '<div id="toast" class="toast"></div>' +
2850
+ '<script id="initial-state" type="application/json">' + initialState + '</script>' +
2851
+ '<script>' +
2852
+ '(function(){' +
2853
+ 'const refreshMs=5000;' +
2854
+ 'let dashboardState=JSON.parse(document.getElementById("initial-state").textContent);' +
2855
+ 'let actionInFlight=false;' +
2856
+ 'let saveScrollState=function(){const state={};document.querySelectorAll("[data-scroll-key]").forEach(function(node){state[node.getAttribute("data-scroll-key")]={top:node.scrollTop,height:node.scrollHeight};});return state;};' +
2857
+ 'let restoreScrollState=function(prev){document.querySelectorAll("[data-scroll-key]").forEach(function(node){const key=node.getAttribute("data-scroll-key");const prior=prev[key];if(!prior)return;const wasNearBottom=prior.height-prior.top-node.clientHeight<24;if(wasNearBottom){node.scrollTop=node.scrollHeight;}else{node.scrollTop=Math.min(prior.top,node.scrollHeight);}});};' +
2858
+ 'let saveDetailsState=function(){const state={};document.querySelectorAll("details[data-key]").forEach(function(node){state[node.getAttribute("data-key")]=node.open;});return state;};' +
2859
+ 'let restoreDetailsState=function(prev){document.querySelectorAll("details[data-key]").forEach(function(node){const key=node.getAttribute("data-key");if(Object.prototype.hasOwnProperty.call(prev,key)){node.open=prev[key];}});};' +
2860
+ 'function escapeHtml(value){return String(value==null?"":value).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/\\x27/g,"&#39;");}' +
2861
+ 'function formatTime(value){if(!value)return "n/a";try{return new Date(value).toLocaleString();}catch(_){return value;}}' +
2862
+ 'function statusTone(kind){return kind===true||kind==="connected"||kind==="running"?"ok":kind==="connecting"||kind==="partial"?"warn":"bad";}' +
2863
+ 'function badge(label,tone){return \'<span class="badge \'+tone+\'">\'+escapeHtml(label)+\'</span>\';}' +
2864
+ 'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
2865
+ 'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
2866
+ '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>\';}' +
2867
+ '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 tmuxHealthy=state.tmux&&state.tmux.available&&state.tmux.sessionExists;const cards=[' +
2868
+ '{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")},' +
2869
+ '{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")},' +
2870
+ '{title:"Tmux",value:tmuxHealthy?"Ready":"Attention",label:"Dispatch workspace",tone:tmuxHealthy?"ok":(state.tmux&&state.tmux.available?"warn":"bad"),sub:state.tmux&&state.tmux.available?(state.tmux.sessionExists?String((state.tmux.livePanes||[]).length)+" live pane(s)":"tmux installed, session missing"):"tmux not installed"},' +
2871
+ '{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"},' +
2872
+ '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
2873
+ '];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("");}' +
2874
+ 'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const tracked=state.tmux&&state.tmux.trackedPanes?Object.keys(JSON.parse(state.tmux.trackedPanes||"{}")).length:0;document.getElementById("summary-grid").innerHTML=[' +
2875
+ 'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
2876
+ 'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
2877
+ 'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
2878
+ 'summaryItem("Connections",String(ws.length)),' +
2879
+ 'summaryItem("Topics",String(topics)),' +
2880
+ 'summaryItem("Tracked Panes",String(tracked))' +
2881
+ '].join("");}' +
2882
+ '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>\':"");}' +
2883
+ '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("");}' +
2884
+ '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("");}' +
2885
+ 'function renderTmux(state){const target=document.getElementById("tmux-section");const panes=state.tmux&&state.tmux.livePanes||[];const trackedText=state.tmux&&state.tmux.trackedPanes?state.tmux.trackedPanes:"{}";const launchSupported=state.tmux&&state.tmux.terminalLaunchSupported;const attachCommand="tmux attach -t "+(state.tmux&&state.tmux.sessionName||"remits-listener");const health=\'<div class="mini-grid"><div class="mini-card"><strong>tmux Installed</strong>\'+escapeHtml(String(state.tmux&&state.tmux.available))+\'</div><div class="mini-card"><strong>Session Exists</strong>\'+escapeHtml(String(state.tmux&&state.tmux.sessionExists))+\'</div><div class="mini-card"><strong>Session Name</strong>\'+escapeHtml(state.tmux&&state.tmux.sessionName||"n/a")+\'</div><div class="mini-card"><strong>Live Panes</strong>\'+escapeHtml(String(panes.length))+\'</div><div class="mini-card"><strong>Open from Dashboard</strong>\'+escapeHtml(launchSupported?"supported":"not supported")+\'</div></div><div class="muted" style="margin-bottom:14px;">\'+escapeHtml(launchSupported?"Use the Open Tmux Terminal button to bring Terminal forward and attach automatically.":"Dashboard-to-terminal launch is not supported on this platform. Run the attach command manually.")+\'</div>\';const quickAttach=detailsBlock("tmux:attach","Quick attach command","manual fallback",codeBlock("tmux-attach-cmd",attachCommand),false);const paneSection=detailsBlock("tmux:panes","Live panes",escapeHtml(String(panes.length))+" pane(s)",codeBlock("tmux-live-panes",JSON.stringify(panes,null,2),"log"),false);const trackedSection=detailsBlock("tmux:tracked","Tracked pane routing","ticket/task to pane mapping",codeBlock("tmux-tracked",trackedText),false);const logFile=(state.globalFiles||[]).find(function(file){return file.label==="tmux-activity.log (tail)";});const logSection=detailsBlock("tmux:activity","Activity tail","stream of tmux and websocket events",codeBlock("tmux-log",logFile&&logFile.content||"[missing]","log"),true);target.innerHTML=health+quickAttach+paneSection+trackedSection+logSection;const openButton=document.querySelector(\'[data-action="open-tmux"]\');if(openButton){openButton.disabled=!launchSupported||!(state.tmux&&state.tmux.available);}}' +
2886
+ 'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="tmux-activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
2887
+ '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("");}' +
2888
+ '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);renderIssues(state);renderWebsockets(state);renderTmux(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
2889
+ '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();}' +
2890
+ '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);}' +
2891
+ 'async function triggerAction(action,button){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:"action="+encodeURIComponent(action)});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
2892
+ 'document.querySelectorAll("[data-action]").forEach(function(button){button.addEventListener("click",function(){triggerAction(button.getAttribute("data-action"),button);});});' +
2893
+ 'renderAll(dashboardState);' +
2894
+ 'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
2895
+ '})();' +
2896
+ '</script>' +
2897
+ '</body></html>';
2898
+ }
2899
+
2900
+ async function reconnectManagedWebsockets() {
2901
+ const clients = managedClients.slice();
2902
+ for (const managedClient of clients) {
2903
+ if (!managedClient || !managedClient.client) {
2904
+ continue;
2905
+ }
2906
+ try {
2907
+ await managedClient.client.deactivate();
2908
+ } catch (_) {
2909
+ // Continue reconnecting the rest.
2910
+ }
2911
+ }
2912
+ for (const managedClient of clients) {
2913
+ if (!managedClient || !managedClient.client) {
2914
+ continue;
2915
+ }
2916
+ try {
2917
+ managedClient.lastConnectAttemptAt = Date.now();
2918
+ managedClient.client.activate();
2919
+ } catch (_) {
2920
+ // Continue reconnecting the rest.
2921
+ }
2922
+ }
2923
+ }
2924
+
2925
+ function startDashboardServer(preferredPort) {
2926
+ return new Promise((resolve, reject) => {
2927
+ const server = http.createServer(async (req, res) => {
2928
+ const requestUrl = new URL(req.url, 'http://' + DASHBOARD_HOST + ':' + (runtimeState.service.dashboardPort || preferredPort));
2929
+ if (req.method === 'GET' && requestUrl.pathname === '/api/state') {
2930
+ res.statusCode = 200;
2931
+ res.setHeader('Content-Type', 'application/json');
2932
+ res.end(JSON.stringify(collectDashboardSnapshot(), null, 2));
2933
+ return;
2934
+ }
2935
+
2936
+ if (req.method === 'POST' && requestUrl.pathname === '/action') {
2937
+ let body = '';
2938
+ req.on('data', (chunk) => {
2939
+ body += chunk.toString();
2940
+ });
2941
+ req.on('end', async () => {
2942
+ const params = new URLSearchParams(body);
2943
+ const action = params.get('action');
2944
+ let actionResult = null;
2945
+ runtimeState.lastAction = {
2946
+ action,
2947
+ at: new Date().toISOString()
2948
+ };
2949
+ if (action === 'export-issues') {
2950
+ const bundlePath = exportIssueBundle(collectDashboardSnapshot());
2951
+ actionResult = { bundlePath };
2952
+ runtimeState.lastAction.bundlePath = bundlePath;
2953
+ } else if (action === 'rescan-repos') {
2954
+ recordDiscoverySummary(discoverAccountRepos());
2955
+ } else if (action === 'open-tmux') {
2956
+ openTmuxSessionForUser();
2957
+ } else if (action === 'start-tmux') {
2958
+ ensureTmuxSession(os.homedir());
2959
+ } else if (action === 'stop-tmux') {
2960
+ killTmuxSession();
2961
+ } else if (action === 'reconnect-websockets') {
2962
+ await reconnectManagedWebsockets();
2963
+ }
2964
+ const wantsJson = String(req.headers.accept || '').includes('application/json');
2965
+ if (wantsJson) {
2966
+ res.statusCode = 200;
2967
+ res.setHeader('Content-Type', 'application/json');
2968
+ res.end(JSON.stringify({
2969
+ success: true,
2970
+ action,
2971
+ result: actionResult,
2972
+ state: collectDashboardSnapshot()
2973
+ }));
2974
+ return;
2975
+ }
2976
+ res.statusCode = 302;
2977
+ res.setHeader('Location', '/');
2978
+ res.end('');
2979
+ });
2980
+ return;
2981
+ }
2982
+
2983
+ if (req.method === 'GET' && requestUrl.pathname === '/') {
2984
+ res.statusCode = 200;
2985
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
2986
+ res.end(renderDashboardHtml(collectDashboardSnapshot()));
2987
+ return;
2988
+ }
2989
+
2990
+ res.statusCode = 404;
2991
+ res.end('Not found');
2992
+ });
2993
+
2994
+ const bind = (port, allowFallback) => {
2995
+ server.once('error', (err) => {
2996
+ if (allowFallback && err && err.code === 'EADDRINUSE') {
2997
+ bind(0, false);
2998
+ return;
2999
+ }
3000
+ reject(err);
3001
+ });
3002
+ server.listen(port, DASHBOARD_HOST, () => {
3003
+ const address = server.address();
3004
+ const port = address && typeof address === 'object' ? address.port : preferredPort;
3005
+ dashboardServer = server;
3006
+ runtimeState.service.dashboardPort = port;
3007
+ runtimeState.service.dashboardUrl = 'http://' + DASHBOARD_HOST + ':' + port + '/';
3008
+ updateRuntimeServiceState();
3009
+ resolve(server);
3010
+ });
3011
+ };
3012
+
3013
+ bind(preferredPort, true);
3014
+ });
3015
+ }
3016
+
3017
+ // --- Agent CLI dispatch via tmux ---
3018
+ const TMUX_SESSION_NAME = 'remits-listener';
3019
+ const DISPATCH_STATE_FILE = path.join(SESSION_DIR, 'dispatch-panes.json');
3020
+
3021
+ // In-memory map of support-ticket routing key -> tmux pane ID (persisted for crash recovery)
3022
+ let paneMap = {};
3023
+
3024
+ function dispatchTicketId(message) {
3025
+ if (!message || typeof message !== 'object') return null;
3026
+ if (message.ticketId !== undefined && message.ticketId !== null && String(message.ticketId).trim()) {
3027
+ return String(message.ticketId).trim();
3028
+ }
3029
+ return null;
3030
+ }
3031
+
3032
+ function dispatchRoutingKey(message) {
3033
+ const ticketId = dispatchTicketId(message);
3034
+ if (ticketId) return 'ticket:' + ticketId;
3035
+ if (message && message.taskId !== undefined && message.taskId !== null && String(message.taskId).trim()) {
3036
+ return 'task:' + String(message.taskId).trim();
3037
+ }
3038
+ return null;
3039
+ }
3040
+
3041
+ function loadPaneMap() {
3042
+ if (fs.existsSync(DISPATCH_STATE_FILE)) {
3043
+ try {
3044
+ paneMap = JSON.parse(fs.readFileSync(DISPATCH_STATE_FILE, 'utf8'));
3045
+ } catch (_) {
3046
+ paneMap = {};
3047
+ }
3048
+ }
3049
+ }
3050
+
3051
+ function savePaneMap() {
3052
+ ensureSessionDir();
3053
+ atomicWriteFile(DISPATCH_STATE_FILE, JSON.stringify(paneMap, null, 2));
3054
+ }
3055
+
3056
+ function isTmuxAvailable() {
3057
+ try {
3058
+ execSync('which tmux', { stdio: 'ignore' });
3059
+ return true;
3060
+ } catch (_) {
3061
+ return false;
3062
+ }
3063
+ }
3064
+
3065
+ function tmuxSessionExists() {
3066
+ try {
3067
+ execSync('tmux has-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
3068
+ return true;
3069
+ } catch (_) {
3070
+ return false;
3071
+ }
3072
+ }
3073
+
3074
+ function tmuxPaneIsAlive(paneId) {
3075
+ try {
3076
+ // Check both that the pane exists AND that it's not dead
3077
+ const output = execSync(
3078
+ 'tmux list-panes -a -F ' + shellQuote('#{pane_id} #{pane_dead}'),
3079
+ { stdio: ['ignore', 'pipe', 'ignore'] }
3080
+ ).toString();
3081
+ const lines = output.split('\n').map((l) => l.trim()).filter(Boolean);
3082
+ for (const line of lines) {
3083
+ const [id, dead] = line.split(' ');
3084
+ if (id === paneId) {
3085
+ return dead !== '1';
3086
+ }
3087
+ }
3088
+ return false; // pane not found at all
3089
+ } catch (_) {
3090
+ return false;
3091
+ }
3092
+ }
3093
+
3094
+ function killPane(paneId) {
3095
+ try {
3096
+ execSync('tmux kill-pane -t ' + shellQuote(paneId), { stdio: 'ignore' });
3097
+ appendGlobalActivityLog('tmux', 'pane.killed', { paneId });
3098
+ } catch (_) {}
3099
+ }
3100
+
3101
+ function killTmuxSession() {
3102
+ if (!tmuxSessionExists()) return;
3103
+ try {
3104
+ execSync('tmux kill-session -t ' + shellQuote(TMUX_SESSION_NAME), { stdio: 'ignore' });
3105
+ appendGlobalActivityLog('tmux', 'session.killed', { sessionName: TMUX_SESSION_NAME });
3106
+ } catch (_) {}
3107
+ }
3108
+
3109
+ function configureTmuxSession() {
3110
+ const commands = [
3111
+ 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' mouse on',
3112
+ 'tmux set-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' history-limit 100000',
3113
+ 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' remain-on-exit on',
3114
+ 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' scroll-on-clear on',
3115
+ 'tmux set-window-option -t ' + shellQuote(TMUX_SESSION_NAME) + ' automatic-rename off'
3116
+ ];
3117
+ for (const command of commands) {
3118
+ try {
3119
+ execSync(command, { stdio: 'ignore' });
3120
+ } catch (_) {}
3121
+ }
3122
+ }
3123
+
3124
+ function ensureTmuxSession(accountDir) {
3125
+ if (!isTmuxAvailable()) {
3126
+ appendGlobalActivityLog('tmux', 'session.create_skipped.tmux_unavailable', { accountDir }, 'error');
3127
+ return false;
3128
+ }
3129
+ if (tmuxSessionExists()) return;
3130
+ execSync(
3131
+ 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(accountDir),
3132
+ { stdio: 'ignore' }
3133
+ );
3134
+ configureTmuxSession();
3135
+ console.log('[listener] Created tmux session:', TMUX_SESSION_NAME);
3136
+ appendGlobalActivityLog('tmux', 'session.created', {
3137
+ sessionName: TMUX_SESSION_NAME,
3138
+ accountDir
3139
+ });
3140
+ return true;
3141
+ }
3142
+
3143
+ function canLaunchTerminalForTmux() {
3144
+ return process.platform === 'darwin';
3145
+ }
3146
+
3147
+ function openTmuxSessionForUser() {
3148
+ if (!isTmuxAvailable()) {
3149
+ throw new Error('tmux is not installed');
3150
+ }
3151
+
3152
+ if (!tmuxSessionExists()) {
3153
+ ensureTmuxSession(os.homedir());
3154
+ }
3155
+
3156
+ const attachCommand = 'tmux attach -t ' + TMUX_SESSION_NAME + ' || tmux new-session -A -s ' + TMUX_SESSION_NAME;
3157
+
3158
+ if (process.platform === 'darwin') {
3159
+ const osaScript = [
3160
+ 'tell application "Terminal"',
3161
+ 'activate',
3162
+ 'do script ' + JSON.stringify(attachCommand),
3163
+ 'end tell'
3164
+ ].join('\n');
3165
+ execSync('osascript -e ' + shellQuote(osaScript), { stdio: 'ignore' });
3166
+ appendGlobalActivityLog('tmux', 'terminal.opened', {
3167
+ sessionName: TMUX_SESSION_NAME,
3168
+ command: attachCommand
3169
+ });
3170
+ return {
3171
+ success: true,
3172
+ method: 'terminal',
3173
+ command: attachCommand
3174
+ };
3175
+ }
3176
+
3177
+ throw new Error('Opening a terminal window is not supported on this platform yet. Run `' + attachCommand + '` manually.');
3178
+ }
3179
+
3180
+ function dispatchWindowName(accountId, routingKey) {
3181
+ const base = routingKey || ('account:' + accountId) || 'dispatch';
3182
+ return base
3183
+ .replace(/[^a-zA-Z0-9:_-]+/g, '-')
3184
+ .slice(0, 40) || 'dispatch';
3185
+ }
3186
+
3187
+ function resolveAgentCommand(agent, prompt) {
3188
+ const escapedPrompt = prompt.replace(/'/g, "'\"'\"'");
3189
+ // All agents are launched in interactive mode so the user can watch and interact.
3190
+ // claude: positional arg starts interactive session with initial prompt
3191
+ // codex: positional arg starts interactive session with initial prompt
3192
+ // gemini: positional arg starts interactive session with initial prompt
3193
+ switch (agent) {
3194
+ case 'claude':
3195
+ return "claude '" + escapedPrompt + "'";
3196
+ case 'codex':
3197
+ return "codex '" + escapedPrompt + "'";
3198
+ case 'gemini':
3199
+ return "gemini '" + escapedPrompt + "'";
3200
+ default:
3201
+ return "claude '" + escapedPrompt + "'";
3202
+ }
3203
+ }
3204
+
3205
+ function resolveAccountDir(accountId) {
3206
+ if (!fs.existsSync(ACCOUNT_REPO_INDEX_FILE)) return null;
3207
+ try {
3208
+ const index = JSON.parse(fs.readFileSync(ACCOUNT_REPO_INDEX_FILE, 'utf8'));
3209
+ const entry = index[String(accountId)];
3210
+ if (entry && entry.directory && fs.existsSync(entry.directory)) {
3211
+ return entry.directory;
3212
+ }
3213
+ } catch (_) {}
3214
+ return null;
3215
+ }
3216
+
3217
+ function normalizeDispatchAccountId(value) {
3218
+ if (value === undefined || value === null) return null;
3219
+ const trimmed = String(value).trim();
3220
+ return trimmed ? trimmed : null;
3221
+ }
3222
+
3223
+ function resolveDispatchAccountId(message, fallbackAccountId) {
3224
+ const candidates = [
3225
+ normalizeDispatchAccountId(message && message.implementationAccountId),
3226
+ normalizeDispatchAccountId(message && message.accountId),
3227
+ normalizeDispatchAccountId(fallbackAccountId)
3228
+ ].filter(Boolean);
3229
+
3230
+ const uniqueCandidates = [];
3231
+ for (const candidate of candidates) {
3232
+ if (!uniqueCandidates.includes(candidate)) {
3233
+ uniqueCandidates.push(candidate);
3234
+ }
3235
+ }
3236
+
3237
+ for (const candidate of uniqueCandidates) {
3238
+ if (resolveAccountDir(candidate)) {
3239
+ return candidate;
3240
+ }
3241
+ }
3242
+
3243
+ return uniqueCandidates[0] || null;
3244
+ }
3245
+
3246
+ function sendKeysToPane(paneId, text) {
3247
+ // Paste through a tmux buffer so multiline follow-ups are injected reliably
3248
+ // into interactive agent UIs, then submit them explicitly.
3249
+ const bufferName = 'remits-cli-' + crypto.randomUUID();
3250
+ try {
3251
+ execSync(
3252
+ 'tmux load-buffer -b ' + shellQuote(bufferName) + ' -',
3253
+ { input: text, stdio: ['pipe', 'ignore', 'ignore'] }
3254
+ );
3255
+ execSync(
3256
+ 'tmux paste-buffer -d -b ' + shellQuote(bufferName) + ' -t ' + shellQuote(paneId),
3257
+ { stdio: 'ignore' }
3258
+ );
3259
+ execSync(
3260
+ 'tmux send-keys -t ' + shellQuote(paneId) + ' Enter',
3261
+ { stdio: 'ignore' }
3262
+ );
3263
+ } finally {
3264
+ try {
3265
+ execSync('tmux delete-buffer -b ' + shellQuote(bufferName), { stdio: 'ignore' });
3266
+ } catch (_) {}
3267
+ }
3268
+ }
3269
+
3270
+ function buildDispatchPrompt(message, options) {
3271
+ const dispatchOptions = options && typeof options === 'object' ? options : {};
3272
+ const textPrompt = message && (message.prompt || message.message);
3273
+ const context = message && typeof message === 'object' ? Object.assign({}, message) : {};
3274
+ delete context.prompt;
3275
+ delete context.message;
3276
+
3277
+ const instructionLines = [
3278
+ 'This message was delivered by the remits-cli tool from an inbound platform message.',
3279
+ 'When triaging and handling this request, load and use the remits-cli skill.'
3280
+ ];
3281
+
3282
+ if (dispatchOptions.noAccountDir === true) {
3283
+ instructionLines.push('remits-cli dispatch status: no_account_dir.');
3284
+ 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.');
3285
+ }
3286
+
3287
+ if (textPrompt) {
3288
+ instructionLines.push('');
3289
+ instructionLines.push('Inbound message:');
3290
+ instructionLines.push(textPrompt);
3291
+ }
3292
+
3293
+ if (Object.keys(context).length) {
3294
+ instructionLines.push('');
3295
+ instructionLines.push('Dispatch context:');
3296
+ instructionLines.push('```json');
3297
+ instructionLines.push(JSON.stringify(context, null, 2));
3298
+ instructionLines.push('```');
3299
+ }
3300
+
3301
+ return instructionLines.join('\n');
3302
+ }
3303
+
3304
+ function dispatchToAgent(accountId, message) {
3305
+ const accountDir = resolveAccountDir(accountId);
3306
+
3307
+ if (!isTmuxAvailable()) {
3308
+ console.error('[listener] tmux is not installed. Cannot dispatch agent session.');
3309
+ appendGlobalActivityLog('tmux', 'dispatch.skipped.tmux_unavailable', {
3310
+ accountId,
3311
+ accountDir,
3312
+ message: summarizeDispatchMessage(message)
3313
+ }, 'error');
3314
+ return;
3315
+ }
3316
+
3317
+ const ticketId = dispatchTicketId(message);
3318
+ const routingKey = dispatchRoutingKey(message);
3319
+ const prompt = buildDispatchPrompt(message, {
3320
+ noAccountDir: !accountDir
3321
+ });
3322
+
3323
+ if (!accountDir) {
3324
+ console.error('[listener] No known directory for account', accountId, '- dispatching anyway');
3325
+ appendGlobalActivityLog('tmux', 'dispatch.no_account_dir', {
3326
+ accountId,
3327
+ message: summarizeDispatchMessage(message)
3328
+ }, 'error');
3329
+ }
3330
+
3331
+ // If this ticket already has a tracked pane, check its state
3332
+ if (routingKey && paneMap[routingKey]) {
3333
+ const existingPaneId = paneMap[routingKey];
3334
+ if (tmuxPaneIsAlive(existingPaneId)) {
3335
+ // Pane is alive, so deliver the follow-up to the existing ticket workstream
3336
+ console.log('[listener] Delivering follow-up for', routingKey, 'to existing pane', existingPaneId);
3337
+ appendGlobalActivityLog('tmux', 'dispatch.follow_up', {
3338
+ accountId,
3339
+ accountDir,
3340
+ ticketId,
3341
+ routingKey,
3342
+ paneId: existingPaneId,
3343
+ message: summarizeDispatchMessage(message)
3344
+ });
3345
+ try {
3346
+ sendKeysToPane(existingPaneId, prompt);
3347
+ return;
3348
+ } catch (err) {
3349
+ console.error('[listener] Failed to send to pane', existingPaneId, ':', err.message, '- creating new pane');
3350
+ appendGlobalActivityLog('tmux', 'dispatch.follow_up_failed', {
3351
+ accountId,
3352
+ accountDir,
3353
+ ticketId,
3354
+ routingKey,
3355
+ paneId: existingPaneId,
3356
+ error: err.message,
3357
+ message: summarizeDispatchMessage(message)
3358
+ }, 'error');
3359
+ }
3360
+ }
3361
+ // Pane is dead or gone, so replace it for this ticket/task routing key.
3362
+ console.log('[listener] Pane', existingPaneId, 'for', routingKey, 'is dead or gone - replacing');
3363
+ appendGlobalActivityLog('tmux', 'dispatch.pane_replaced', {
3364
+ accountId,
3365
+ accountDir,
3366
+ ticketId,
3367
+ routingKey,
3368
+ paneId: existingPaneId,
3369
+ message: summarizeDispatchMessage(message)
3370
+ });
3371
+ killPane(existingPaneId);
3372
+ delete paneMap[routingKey];
3373
+ savePaneMap();
3374
+ }
3375
+
3376
+ // Create a new pane for this task
3377
+ try {
3378
+ const paneDir = accountDir || os.homedir();
3379
+ ensureTmuxSession(paneDir);
3380
+
3381
+ const agent = getPreferredAgent();
3382
+ const agentCmd = resolveAgentCommand(agent, prompt);
3383
+ const windowName = dispatchWindowName(accountId, routingKey);
3384
+
3385
+ // Wrap in a login shell so the pane inherits the user's full PATH
3386
+ // (the daemonized listener process has a minimal environment)
3387
+ const userShell = process.env.SHELL || '/bin/zsh';
3388
+ const loginCmd = userShell + ' -l -c ' + shellQuote(agentCmd);
3389
+
3390
+ // Create a dedicated window for this workstream so full-screen agent UIs
3391
+ // remain readable and easy to inspect.
3392
+ const paneInfo = execSync(
3393
+ 'tmux new-window -t ' + shellQuote(TMUX_SESSION_NAME) +
3394
+ ' -n ' + shellQuote(windowName) +
3395
+ ' -c ' + shellQuote(paneDir) +
3396
+ ' -P -F ' + shellQuote('#{pane_id}') +
3397
+ ' ' + shellQuote(loginCmd),
3398
+ { stdio: ['ignore', 'pipe', 'ignore'] }
3399
+ ).toString().trim();
3400
+
3401
+ console.log('[listener] Dispatched', routingKey || '(untracked message)', 'to', agent, 'in pane', paneInfo, 'at', accountDir);
3402
+ appendGlobalActivityLog('tmux', 'dispatch.created', {
3403
+ accountId,
3404
+ accountDir: paneDir,
3405
+ resolvedAccountDir: accountDir,
3406
+ ticketId,
3407
+ routingKey,
3408
+ paneId: paneInfo,
3409
+ agent,
3410
+ message: summarizeDispatchMessage(message)
3411
+ });
3412
+
3413
+ if (routingKey) {
3414
+ paneMap[routingKey] = paneInfo;
3415
+ savePaneMap();
3416
+ }
3417
+ } catch (err) {
3418
+ console.error('[listener] Failed to create tmux pane:', err.message);
3419
+ appendGlobalActivityLog('tmux', 'dispatch.create_failed', {
3420
+ accountId,
3421
+ accountDir,
3422
+ ticketId,
3423
+ routingKey,
3424
+ error: err.message,
3425
+ agent: getPreferredAgent(),
3426
+ message: summarizeDispatchMessage(message)
3427
+ }, 'error');
3428
+ }
3429
+ }
3430
+
3431
+ function publishMissedMessageRequest(client, baseUrl, userId) {
3432
+ if (!userId) {
3433
+ return;
3434
+ }
3435
+ const since = lastMessageTimestampForScope(baseUrl, userId);
3436
+ client.publish({
3437
+ destination: '/app/missed-messages',
3438
+ body: JSON.stringify({
3439
+ userId: String(userId),
3440
+ since
3441
+ })
3442
+ });
3443
+ appendGlobalActivityLog('listener', 'websocket.missed_messages.requested', {
3444
+ baseUrl,
3445
+ userId,
3446
+ since
3447
+ });
3448
+ }
3449
+
3450
+ function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
3451
+ if (!messageId) {
3452
+ return;
3453
+ }
3454
+ const primaryUserId = userIds.find(Boolean);
3455
+ const body = primaryUserId
3456
+ ? { messageId, userId: String(primaryUserId) }
3457
+ : { messageId };
3458
+ client.publish({
3459
+ destination: '/app/ack',
3460
+ body: JSON.stringify(body)
3461
+ });
3462
+ }
3463
+
3464
+ function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, userIds, source, rawMessage) {
3465
+ const data = rawMessage && typeof rawMessage === 'object' ? rawMessage : {};
3466
+ const messageId = data.messageId ? String(data.messageId) : null;
3467
+ const duplicate = messageId ? hasProcessedMessageId(messageId) : false;
3468
+
3469
+ recordWebsocketMessage(baseUrl, topic, data, {
3470
+ source,
3471
+ duplicate,
3472
+ accountIds
3473
+ });
3474
+
3475
+ if (messageId) {
3476
+ acknowledgeWebsocketMessage(client, messageId, userIds);
3477
+ }
3478
+
3479
+ updateLastMessageTimestamp(baseUrl, userIds, data.timestamp);
3480
+ runtimeState.websocket[baseUrl].lastMessageAt = new Date().toISOString();
3481
+ if (runtimeState.websocket[baseUrl].topics[topic]) {
3482
+ runtimeState.websocket[baseUrl].topics[topic].lastMessageAt = runtimeState.websocket[baseUrl].lastMessageAt;
3483
+ }
3484
+
3485
+ if (duplicate) {
3486
+ appendGlobalActivityLog('listener', 'websocket.duplicate_ignored', {
3487
+ baseUrl,
3488
+ topic,
3489
+ messageId,
3490
+ source
3491
+ });
3492
+ updateRuntimeServiceState();
3493
+ return;
3494
+ }
3495
+
3496
+ if (messageId) {
3497
+ rememberProcessedMessageId(messageId);
3498
+ }
3499
+
3500
+ if (data.type === 'TestSuite') {
3501
+ const payload = data.payload || {};
3502
+ const payloadTaskId = payload.taskId || null;
3503
+ const currentTestTaskId = runtimeState.currentTestTaskId || null;
3504
+ if (payloadTaskId && currentTestTaskId && payloadTaskId !== currentTestTaskId) {
3505
+ updateRuntimeServiceState();
3506
+ return;
3507
+ }
3508
+ if (payload.tests || payload.name || payload.total != null) {
3509
+ const acctId = payload.accountId || accountIds[0];
3510
+ console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
3511
+ }
3512
+ } else if (data.type === 'remits-cli') {
3513
+ const payload = data.payload || data;
3514
+ const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
3515
+ console.log('[ws][remits-cli][account:' + dispatchAccountId + ']', JSON.stringify(payload));
3516
+ appendGlobalActivityLog('tmux', 'dispatch.received', {
3517
+ baseUrl,
3518
+ topic,
3519
+ accountId: dispatchAccountId,
3520
+ source,
3521
+ messageId,
3522
+ message: summarizeDispatchMessage(payload)
3523
+ });
3524
+ dispatchToAgent(dispatchAccountId, payload);
3525
+ }
3526
+
3527
+ updateRuntimeServiceState();
3528
+ }
3529
+
3530
+ function createManagedWebsocketClient(baseUrl, urlSessions) {
3531
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
3532
+ const managedClient = {
3533
+ baseUrl: normalizedBaseUrl,
3534
+ urlSessions: Array.isArray(urlSessions) ? urlSessions.slice() : [],
3535
+ client: null,
3536
+ lastConnectAttemptAt: null,
3537
+ lastConnectedAt: null,
3538
+ lastDisconnectedAt: null
3539
+ };
3540
+
3541
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3542
+ runtimeState.websocket[normalizedBaseUrl] = {
3543
+ ...runtimeState.websocket[normalizedBaseUrl],
3544
+ status: 'connecting',
3545
+ connectedAt: runtimeState.websocket[normalizedBaseUrl].connectedAt || null,
3546
+ disconnectedAt: runtimeState.websocket[normalizedBaseUrl].disconnectedAt || null,
3547
+ lastError: null
3548
+ };
3549
+
3550
+ const client = new Client({
3551
+ brokerURL: webSocketUrl(normalizedBaseUrl),
3552
+ reconnectDelay: WEBSOCKET_RECONNECT_DELAY_MS,
3553
+ heartbeatIncoming: WEBSOCKET_HEARTBEAT_MS,
3554
+ heartbeatOutgoing: WEBSOCKET_HEARTBEAT_MS,
3555
+ connectionTimeout: WEBSOCKET_CONNECTION_TIMEOUT_MS,
3556
+ discardWebsocketOnCommFailure: true,
3557
+ debug: () => {},
3558
+ beforeConnect: () => {
3559
+ managedClient.lastConnectAttemptAt = Date.now();
3560
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3561
+ runtimeState.websocket[normalizedBaseUrl] = {
3562
+ ...runtimeState.websocket[normalizedBaseUrl],
3563
+ status: 'connecting',
3564
+ lastError: null
3565
+ };
3566
+ updateRuntimeServiceState();
3567
+ }
3568
+ });
3569
+
3570
+ client.onConnect = () => {
3571
+ managedClient.lastConnectedAt = Date.now();
3572
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3573
+ runtimeState.websocket[normalizedBaseUrl] = {
3574
+ ...runtimeState.websocket[normalizedBaseUrl],
3575
+ status: 'connected',
3576
+ connectedAt: new Date().toISOString(),
3577
+ disconnectedAt: null,
3578
+ lastError: null,
3579
+ topics: {}
3580
+ };
3581
+ console.log('[listener] Connected to', normalizedBaseUrl);
3582
+ appendGlobalActivityLog('listener', 'websocket.connected', { baseUrl: normalizedBaseUrl });
3583
+ updateRuntimeServiceState();
3584
+
3585
+ const subscribedTopics = new Set();
3586
+ const accountsByTopic = {};
3587
+ const userIds = [];
3588
+ for (const session of managedClient.urlSessions) {
3589
+ if (!session || !session.websocketTopic) {
3590
+ continue;
3591
+ }
3592
+ if (!accountsByTopic[session.websocketTopic]) {
3593
+ accountsByTopic[session.websocketTopic] = [];
3594
+ }
3595
+ accountsByTopic[session.websocketTopic].push(session.accountId);
3596
+ const userId = session.user && session.user.id ? String(session.user.id) : null;
3597
+ if (userId && !userIds.includes(userId)) {
3598
+ userIds.push(userId);
3599
+ }
3600
+ }
3601
+
3602
+ for (const [topic, accountIds] of Object.entries(accountsByTopic)) {
3603
+ if (subscribedTopics.has(topic)) {
3604
+ continue;
3605
+ }
3606
+ subscribedTopics.add(topic);
3607
+ runtimeState.websocket[normalizedBaseUrl].topics[topic] = {
3608
+ accountIds,
3609
+ subscribedAt: new Date().toISOString(),
3610
+ lastMessageAt: null
3611
+ };
3612
+ client.subscribe('/topic/messages/' + topic, (msg) => {
3613
+ try {
3614
+ const data = JSON.parse(msg.body);
3615
+ processInboundWebsocketMessage(client, normalizedBaseUrl, topic, accountIds, userIds, 'live', data);
3616
+ } catch (err) {
3617
+ console.error('[ws] parse error:', err.message);
3618
+ runtimeState.websocket[normalizedBaseUrl] = {
3619
+ ...runtimeState.websocket[normalizedBaseUrl],
3620
+ lastError: err.message
3621
+ };
3622
+ appendGlobalActivityLog('listener', 'websocket.parse_error', {
3623
+ baseUrl: normalizedBaseUrl,
3624
+ topic,
3625
+ error: err.message
3626
+ }, 'error');
3627
+ updateRuntimeServiceState();
3628
+ }
3629
+ });
3630
+ console.log('[listener] Subscribed to topic', topic, '(accounts:', accountIds.join(', ') + ')');
3631
+ appendGlobalActivityLog('listener', 'websocket.subscribed', {
3632
+ baseUrl: normalizedBaseUrl,
3633
+ topic,
3634
+ accountIds
3635
+ });
3636
+ updateRuntimeServiceState();
3637
+ }
3638
+
3639
+ for (const userId of userIds) {
3640
+ publishMissedMessageRequest(client, normalizedBaseUrl, userId);
3641
+ }
3642
+ };
3643
+
3644
+ client.onDisconnect = () => {
3645
+ managedClient.lastDisconnectedAt = Date.now();
3646
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3647
+ runtimeState.websocket[normalizedBaseUrl] = {
3648
+ ...runtimeState.websocket[normalizedBaseUrl],
3649
+ status: 'disconnected',
3650
+ disconnectedAt: new Date().toISOString()
3651
+ };
3652
+ console.log('[listener] Disconnected from', normalizedBaseUrl, '- will reconnect');
3653
+ appendGlobalActivityLog('listener', 'websocket.disconnected', { baseUrl: normalizedBaseUrl });
3654
+ updateRuntimeServiceState();
3655
+ };
3656
+
3657
+ client.onStompError = (frame) => {
3658
+ const error = frame && frame.headers && frame.headers.message ? frame.headers.message : 'Unknown STOMP error';
3659
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3660
+ runtimeState.websocket[normalizedBaseUrl] = {
3661
+ ...runtimeState.websocket[normalizedBaseUrl],
3662
+ status: 'error',
3663
+ lastError: error
3664
+ };
3665
+ console.error('[listener] STOMP error:', error);
3666
+ appendGlobalActivityLog('listener', 'websocket.stomp_error', {
3667
+ baseUrl: normalizedBaseUrl,
3668
+ error
3669
+ }, 'error');
3670
+ updateRuntimeServiceState();
3671
+ };
3672
+
3673
+ client.onWebSocketClose = (event) => {
3674
+ managedClient.lastDisconnectedAt = Date.now();
3675
+ const detail = describeWebsocketCloseEvent(event);
3676
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3677
+ runtimeState.websocket[normalizedBaseUrl] = {
3678
+ ...runtimeState.websocket[normalizedBaseUrl],
3679
+ status: 'disconnected',
3680
+ disconnectedAt: new Date().toISOString(),
3681
+ lastError: detail
3682
+ };
3683
+ appendGlobalActivityLog('listener', 'websocket.closed', {
3684
+ baseUrl: normalizedBaseUrl,
3685
+ detail
3686
+ }, event && event.wasClean === false ? 'error' : 'info');
3687
+ updateRuntimeServiceState();
3688
+ };
3689
+
3690
+ client.onWebSocketError = (event) => {
3691
+ const detail = describeWebsocketErrorEvent(event);
3692
+ ensureRuntimeWebsocketEntry(normalizedBaseUrl);
3693
+ runtimeState.websocket[normalizedBaseUrl] = {
3694
+ ...runtimeState.websocket[normalizedBaseUrl],
3695
+ status: 'error',
3696
+ lastError: detail
3697
+ };
3698
+ appendGlobalActivityLog('listener', 'websocket.error', {
3699
+ baseUrl: normalizedBaseUrl,
3700
+ detail
3701
+ }, 'error');
3702
+ updateRuntimeServiceState();
3703
+ };
3704
+
3705
+ managedClient.client = client;
3706
+ return managedClient;
3707
+ }
3708
+
3709
+ async function deactivateManagedWebsocketClient(managedClient) {
3710
+ if (!managedClient || !managedClient.client) {
3711
+ return;
3712
+ }
3713
+ try {
3714
+ await managedClient.client.deactivate();
3715
+ } catch (_) {
3716
+ // Best effort during shutdown/replacement.
3717
+ }
3718
+ }
3719
+
3720
+ async function replaceManagedWebsocketClients(entries, reason) {
3721
+ const nextGroups = groupSessionsByBaseUrl(entries);
3722
+ const nextManagedClients = Object.entries(nextGroups).map(([baseUrl, urlSessions]) => createManagedWebsocketClient(baseUrl, urlSessions));
3723
+ const previousClients = managedClients.slice();
3724
+ managedClients = nextManagedClients;
3725
+ runtimeState.websocket = {};
3726
+ for (const managedClient of nextManagedClients) {
3727
+ ensureRuntimeWebsocketEntry(managedClient.baseUrl);
3728
+ }
3729
+ updateRuntimeServiceState();
3730
+
3731
+ for (const managedClient of previousClients) {
3732
+ await deactivateManagedWebsocketClient(managedClient);
3733
+ }
3734
+ for (const managedClient of nextManagedClients) {
3735
+ managedClient.lastConnectAttemptAt = Date.now();
3736
+ managedClient.client.activate();
3737
+ }
3738
+
3739
+ appendGlobalActivityLog('listener', 'websocket.clients.replaced', {
3740
+ reason,
3741
+ connectionCount: nextManagedClients.length
3742
+ });
3743
+ }
3744
+
3745
+ function pruneDeadPaneMappings() {
3746
+ const removed = [];
3747
+ for (const [routingKey, paneId] of Object.entries(paneMap)) {
3748
+ if (tmuxPaneIsAlive(paneId)) {
3749
+ continue;
3750
+ }
3751
+ delete paneMap[routingKey];
3752
+ removed.push({ routingKey, paneId });
3753
+ }
3754
+ if (removed.length) {
3755
+ savePaneMap();
3756
+ appendGlobalActivityLog('tmux', 'dispatch_state.pruned', {
3757
+ removed
3758
+ });
3759
+ }
3760
+ }
3761
+
3762
+ function ensureTmuxSessionHealthy(defaultDir) {
3763
+ if (!isTmuxAvailable()) {
3764
+ return;
3765
+ }
3766
+ if (tmuxSessionExists()) {
3767
+ pruneDeadPaneMappings();
3768
+ return;
3769
+ }
3770
+ const hadPaneMappings = Object.keys(paneMap).length > 0;
3771
+ if (hadPaneMappings) {
3772
+ paneMap = {};
3773
+ savePaneMap();
3774
+ appendGlobalActivityLog('tmux', 'dispatch_state.cleared.missing_session', {
3775
+ sessionName: TMUX_SESSION_NAME
3776
+ }, 'error');
3777
+ }
3778
+ ensureTmuxSession(defaultDir || os.homedir());
3779
+ }
3780
+
3781
+ async function runManagedWebsocketWatchdog() {
3782
+ const now = Date.now();
3783
+ for (const managedClient of managedClients) {
3784
+ if (!managedClient || !managedClient.client) {
3785
+ continue;
3786
+ }
3787
+ const state = ensureRuntimeWebsocketEntry(managedClient.baseUrl);
3788
+ const socket = managedClient.client.webSocket;
3789
+ const readyState = socket && typeof socket.readyState === 'number' ? socket.readyState : null;
3790
+ const disconnectedAtMs = state.disconnectedAt ? Date.parse(state.disconnectedAt) : 0;
3791
+ const lastConnectAttemptAge = managedClient.lastConnectAttemptAt ? (now - managedClient.lastConnectAttemptAt) : 0;
3792
+ const connectedReadyMismatch = state.status === 'connected' && readyState != null && readyState !== WebSocket.OPEN;
3793
+ const stalledConnecting = state.status === 'connecting' && lastConnectAttemptAge > (WEBSOCKET_CONNECTION_TIMEOUT_MS + WEBSOCKET_WATCHDOG_GRACE_MS);
3794
+ const prolongedOutage = (state.status === 'disconnected' || state.status === 'error') &&
3795
+ disconnectedAtMs > 0 &&
3796
+ (now - disconnectedAtMs) > WEBSOCKET_WATCHDOG_GRACE_MS;
3797
+
3798
+ if (!connectedReadyMismatch && !stalledConnecting && !prolongedOutage) {
3799
+ continue;
3800
+ }
3801
+
3802
+ const reason = connectedReadyMismatch
3803
+ ? 'socket_state_mismatch:' + websocketReadyStateName(readyState)
3804
+ : (stalledConnecting ? 'connect_timeout' : 'prolonged_outage');
3805
+ appendGlobalActivityLog('listener', 'websocket.watchdog.reconnect', {
3806
+ baseUrl: managedClient.baseUrl,
3807
+ reason,
3808
+ websocketStatus: state.status,
3809
+ readyState: websocketReadyStateName(readyState)
3810
+ }, 'error');
3811
+ try {
3812
+ await managedClient.client.deactivate();
3813
+ } catch (_) {
3814
+ // Continue into activate so the watchdog can still try to recover.
3815
+ }
3816
+ managedClient.lastConnectAttemptAt = Date.now();
3817
+ try {
3818
+ managedClient.client.activate();
3819
+ } catch (err) {
3820
+ appendGlobalActivityLog('listener', 'websocket.watchdog.activate_failed', {
3821
+ baseUrl: managedClient.baseUrl,
3822
+ reason,
3823
+ error: describeError(err)
3824
+ }, 'error');
3825
+ }
3826
+ }
3827
+ }
3828
+
3829
+ // --- Persistent WebSocket listener ---
3830
+ async function listenCommand(flags) {
3831
+ if (isListenerRunning()) {
3832
+ const state = readServiceState();
3833
+ console.log('remits-cli service is already running. Restarting.');
3834
+ if (state.dashboardUrl) {
3835
+ console.log('Existing control center:', state.dashboardUrl);
3836
+ }
3837
+ await listenStopCommand();
3838
+ const stopped = await waitForListenerStopped(5000);
3839
+ if (!stopped) {
3840
+ throw new Error('Failed to stop the existing remits-cli service before restart.');
3841
+ }
3842
+ }
3843
+
3844
+ const sessions = readAllSessions();
3845
+ const entries = listenerSessionEntriesFromStore(sessions);
3846
+
3847
+ if (!entries.length) {
3848
+ throw new Error('No authenticated sessions with WebSocket topics. Run: remits-cli auth');
3849
+ }
3850
+
3851
+ // Determine if we should daemonize
3852
+ const foreground = flags.foreground === true || flags.foreground === 'true';
3853
+
3854
+ if (!foreground) {
3855
+ const discovery = discoverAccountRepos();
3856
+ // Fork into background
3857
+ const { spawn } = require('child_process');
3858
+ const child = spawn(process.execPath, [__filename, 'start', '--foreground', 'true'], {
3859
+ detached: true,
3860
+ stdio: 'ignore',
3861
+ env: { ...process.env }
3862
+ });
3863
+ child.unref();
3864
+ // Summarize connections and topics for the user
3865
+ const urlGroups = {};
3866
+ for (const s of entries) {
3867
+ const url = s.baseUrl || DEFAULT_BASE_URL;
3868
+ if (!urlGroups[url]) urlGroups[url] = {};
3869
+ if (!urlGroups[url][s.websocketTopic]) urlGroups[url][s.websocketTopic] = [];
3870
+ urlGroups[url][s.websocketTopic].push(s.accountId);
3871
+ }
3872
+ const connectionCount = Object.keys(urlGroups).length;
3873
+ const topicCount = Object.values(urlGroups).reduce((sum, topics) => sum + Object.keys(topics).length, 0);
3874
+
3875
+ console.log('remits-cli service started in background (pid: ' + child.pid + ')');
3876
+ appendGlobalActivityLog('listener', 'background.started', {
3877
+ pid: child.pid,
3878
+ connectionCount,
3879
+ topicCount,
3880
+ accountCount: entries.length,
3881
+ repoCount: discovery.repoCount
3882
+ });
3883
+ const dashboardUrl = await waitForDashboardUrl(5000);
3884
+ console.log(connectionCount + ' connection(s), ' + topicCount + ' topic(s), ' + entries.length + ' account(s)');
3885
+ console.log('Discovered repo index entries:', discovery.repoCount);
3886
+ if (dashboardUrl) {
3887
+ console.log('Control center:', dashboardUrl);
3888
+ if (openBrowser) {
3889
+ try {
3890
+ await openBrowser(dashboardUrl);
3891
+ } catch (_) {
3892
+ // Best-effort browser open; the URL is still printed for manual use.
3893
+ }
3894
+ }
3895
+ } else {
3896
+ console.log('Control center: waiting for dashboard URL, check `remits-cli status` in a moment.');
3897
+ }
3898
+ for (const [url, topics] of Object.entries(urlGroups)) {
3899
+ console.log(' ' + url);
3900
+ for (const [topic, accountIds] of Object.entries(topics)) {
3901
+ console.log(' topic ' + topic + ' -> accounts: ' + accountIds.join(', '));
3902
+ }
3903
+ }
3904
+ console.log('Use: remits-cli stop to stop');
3905
+ console.log('Use: remits-cli status to check');
3906
+ return;
3907
+ }
3908
+
3909
+ // Foreground mode — this is the actual long-running listener
3910
+ // Clean slate: kill any leftover tmux session and pane state from a previous run
3911
+ killTmuxSession();
3912
+ paneMap = {};
3913
+ savePaneMap();
3914
+
3915
+ writeListenerPid();
3916
+ runtimeState.service.startedAt = new Date().toISOString();
3917
+ runtimeState.service.pid = process.pid;
3918
+ recordDiscoverySummary(discoverAccountRepos());
3919
+ const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
3920
+ await startDashboardServer(dashboardPort);
3921
+
3922
+ // Create the tmux session immediately so the user can attach right away
3923
+ try {
3924
+ const defaultDir = os.homedir();
3925
+ execSync(
3926
+ 'tmux new-session -d -s ' + shellQuote(TMUX_SESSION_NAME) + ' -n ' + shellQuote('listener') + ' -c ' + shellQuote(defaultDir),
3927
+ { stdio: 'ignore' }
3928
+ );
3929
+ configureTmuxSession();
3930
+ appendGlobalActivityLog('tmux', 'session.created', {
3931
+ sessionName: TMUX_SESSION_NAME,
3932
+ accountDir: defaultDir,
3933
+ source: 'listener.start'
3934
+ });
3935
+ } catch (err) {
3936
+ console.error('[listener] Failed to create tmux session:', err.message);
3937
+ appendGlobalActivityLog('tmux', 'session.create_failed', {
3938
+ sessionName: TMUX_SESSION_NAME,
3939
+ error: err.message
3940
+ }, 'error');
3941
+ }
3942
+
3943
+ // Clean up on exit — kill tmux session and clear state
3944
+ let refreshInterval = null;
3945
+ const cleanup = () => {
3946
+ appendGlobalActivityLog('listener', 'cleanup', { pid: process.pid });
3947
+ if (refreshInterval) {
3948
+ clearInterval(refreshInterval);
3949
+ }
3950
+ for (const managedClient of managedClients) {
3951
+ try {
3952
+ if (managedClient && managedClient.client) {
3953
+ managedClient.client.deactivate();
3954
+ }
3955
+ } catch (_) {}
3956
+ }
3957
+ managedClients = [];
3958
+ if (dashboardServer) {
3959
+ try {
3960
+ dashboardServer.close();
3961
+ } catch (_) {}
3962
+ dashboardServer = null;
3963
+ }
3964
+ killTmuxSession();
3965
+ paneMap = {};
3966
+ savePaneMap();
3967
+ clearListenerPid();
3968
+ clearServiceState();
3969
+ process.exit(0);
3970
+ };
3971
+ process.on('SIGINT', cleanup);
3972
+ process.on('SIGTERM', cleanup);
3973
+
3974
+ console.log('[listener] Starting remits-cli service (pid: ' + process.pid + ')');
3975
+ console.log('[dashboard] ' + runtimeState.service.dashboardUrl);
3976
+ appendGlobalActivityLog('listener', 'foreground.started', {
3977
+ pid: process.pid,
3978
+ accountCount: entries.length,
3979
+ logFile: TMUX_ACTIVITY_LOG_FILE,
3980
+ dashboardUrl: runtimeState.service.dashboardUrl
3981
+ });
3982
+
3983
+ let activeSessionSignature = listenerSessionSignature(entries);
3984
+ await replaceManagedWebsocketClients(entries, 'listener.start');
3985
+
3986
+ // Periodically check for new/changed sessions and update subscriptions
3987
+ let maintenanceInFlight = false;
3988
+ refreshInterval = setInterval(async () => {
3989
+ if (maintenanceInFlight) {
3990
+ return;
3991
+ }
3992
+ maintenanceInFlight = true;
3993
+ try {
3994
+ ensureTmuxSessionHealthy(os.homedir());
3995
+
3996
+ const currentEntries = listenerSessionEntriesFromStore(readAllSessions());
3997
+ const currentSignature = listenerSessionSignature(currentEntries);
3998
+ if (currentSignature !== activeSessionSignature) {
3999
+ console.log('[listener] Session set changed. Refreshing websocket clients.');
4000
+ appendGlobalActivityLog('listener', 'sessions.changed', {
4001
+ previousConnectionCount: managedClients.length,
4002
+ nextConnectionCount: Object.keys(groupSessionsByBaseUrl(currentEntries)).length
4003
+ });
4004
+ await replaceManagedWebsocketClients(currentEntries, 'sessions.changed');
4005
+ activeSessionSignature = currentSignature;
4006
+ }
4007
+
4008
+ await runManagedWebsocketWatchdog();
4009
+ updateRuntimeServiceState();
4010
+ } catch (err) {
4011
+ appendGlobalActivityLog('listener', 'maintenance.failed', {
4012
+ error: describeError(err)
4013
+ }, 'error');
4014
+ } finally {
4015
+ maintenanceInFlight = false;
4016
+ }
4017
+ }, SERVICE_MAINTENANCE_INTERVAL_MS);
4018
+
4019
+ // Keep the process alive
4020
+ await new Promise(() => {});
4021
+ }
4022
+
4023
+ async function listenStopCommand() {
4024
+ // Kill the WebSocket listener process
4025
+ if (fs.existsSync(LISTENER_PID_FILE)) {
4026
+ try {
4027
+ const pid = parseInt(fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim(), 10);
4028
+ process.kill(pid, 'SIGTERM');
4029
+ console.log('remits-cli service stopped (pid: ' + pid + ')');
4030
+ appendGlobalActivityLog('listener', 'stopped', { pid });
4031
+ } catch (err) {
4032
+ console.log('remits-cli service process not found. Cleaned up PID file.');
4033
+ appendGlobalActivityLog('listener', 'stop.missing_process', { error: err.message }, 'error');
4034
+ }
4035
+ clearListenerPid();
4036
+ } else {
4037
+ console.log('No remits-cli service process was running.');
4038
+ appendGlobalActivityLog('listener', 'stop.no_process');
4039
+ }
4040
+
4041
+ // Kill the tmux session and clear pane tracking
4042
+ if (tmuxSessionExists()) {
4043
+ killTmuxSession();
4044
+ console.log('Killed tmux session:', TMUX_SESSION_NAME);
4045
+ }
4046
+ if (fs.existsSync(DISPATCH_STATE_FILE)) {
4047
+ atomicWriteFile(DISPATCH_STATE_FILE, '{}');
4048
+ appendGlobalActivityLog('tmux', 'dispatch_state.cleared', {
4049
+ file: DISPATCH_STATE_FILE
4050
+ });
4051
+ }
4052
+ clearServiceState();
4053
+ }
4054
+
4055
+ async function waitForListenerStopped(timeoutMs = 5000) {
4056
+ const deadline = Date.now() + timeoutMs;
4057
+ while (Date.now() < deadline) {
4058
+ if (!isListenerRunning()) {
4059
+ return true;
4060
+ }
4061
+ await new Promise((resolve) => setTimeout(resolve, 100));
4062
+ }
4063
+ return !isListenerRunning();
4064
+ }
4065
+
4066
+ async function listenStatusCommand() {
4067
+ const state = readServiceState();
4068
+ if (isListenerRunning()) {
4069
+ const pid = fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim();
4070
+ console.log('remits-cli service is running (pid: ' + pid + ')');
4071
+ if (state.dashboardUrl) {
4072
+ console.log('Dashboard:', state.dashboardUrl);
4073
+ }
4074
+ if (state.discovery && Number.isFinite(state.discovery.repoCount)) {
4075
+ console.log('Indexed repos:', state.discovery.repoCount);
4076
+ }
4077
+ } else {
4078
+ console.log('remits-cli service is not running.');
4079
+ }
4080
+ }
4081
+
4082
+ function npmCommand() {
4083
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
4084
+ }
4085
+
4086
+ function remitsCliCommand() {
4087
+ return process.platform === 'win32' ? 'remits-cli.cmd' : 'remits-cli';
4088
+ }
4089
+
4090
+ function parseSemver(version) {
4091
+ const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
4092
+ if (!match) return null;
4093
+ return {
4094
+ major: Number(match[1]),
4095
+ minor: Number(match[2]),
4096
+ patch: Number(match[3]),
4097
+ prerelease: match[4] ? match[4].split('.') : []
4098
+ };
4099
+ }
4100
+
4101
+ function comparePrereleaseIdentifiers(left, right) {
4102
+ const leftNumber = /^\d+$/.test(left);
4103
+ const rightNumber = /^\d+$/.test(right);
4104
+ if (leftNumber && rightNumber) return Number(left) - Number(right);
4105
+ if (leftNumber) return -1;
4106
+ if (rightNumber) return 1;
4107
+ return left.localeCompare(right);
4108
+ }
4109
+
4110
+ function compareSemver(leftVersion, rightVersion) {
4111
+ const left = parseSemver(leftVersion);
4112
+ const right = parseSemver(rightVersion);
4113
+ if (!left || !right) return String(leftVersion || '').localeCompare(String(rightVersion || ''));
4114
+ for (const key of ['major', 'minor', 'patch']) {
4115
+ if (left[key] !== right[key]) return left[key] - right[key];
4116
+ }
4117
+ if (!left.prerelease.length && right.prerelease.length) return 1;
4118
+ if (left.prerelease.length && !right.prerelease.length) return -1;
4119
+ const max = Math.max(left.prerelease.length, right.prerelease.length);
4120
+ for (let i = 0; i < max; i++) {
4121
+ if (left.prerelease[i] === undefined) return -1;
4122
+ if (right.prerelease[i] === undefined) return 1;
4123
+ const comparison = comparePrereleaseIdentifiers(left.prerelease[i], right.prerelease[i]);
4124
+ if (comparison !== 0) return comparison;
4125
+ }
4126
+ return 0;
4127
+ }
4128
+
4129
+ function acquireAutoUpdateLock() {
4130
+ ensureSessionDir();
4131
+ try {
4132
+ fs.mkdirSync(AUTO_UPDATE_LOCK_DIR);
4133
+ return true;
4134
+ } catch (err) {
4135
+ if (err && err.code === 'EEXIST') {
4136
+ try {
4137
+ const ageMs = Date.now() - fs.statSync(AUTO_UPDATE_LOCK_DIR).mtimeMs;
4138
+ if (ageMs > 10 * 60 * 1000) {
4139
+ fs.rmSync(AUTO_UPDATE_LOCK_DIR, { recursive: true, force: true });
4140
+ fs.mkdirSync(AUTO_UPDATE_LOCK_DIR);
4141
+ return true;
4142
+ }
4143
+ } catch (_) {}
4144
+ return false;
4145
+ }
4146
+ return false;
4147
+ }
4148
+ }
4149
+
4150
+ function releaseAutoUpdateLock() {
4151
+ try {
4152
+ fs.rmSync(AUTO_UPDATE_LOCK_DIR, { recursive: true, force: true });
4153
+ } catch (_) {}
4154
+ }
4155
+
4156
+ function shouldAutoUpdate(command, flags) {
4157
+ if (!command || command === 'help' || command === '--help') return false;
4158
+ if (flags && flags['no-auto-update']) return false;
4159
+ if (process.env.REMITS_CLI_AUTO_UPDATE && AUTO_UPDATE_DISABLED_VALUES.has(String(process.env.REMITS_CLI_AUTO_UPDATE).toLowerCase())) {
4160
+ return false;
4161
+ }
4162
+ if (process.env.REMITS_CLI_AUTO_UPDATED === '1') return false;
4163
+ return true;
4164
+ }
4165
+
4166
+ function autoUpdateIfNeeded(originalArgv, options = {}) {
4167
+ const requireSuccess = Boolean(options.requireSuccess);
4168
+ let lockAcquired = false;
4169
+ try {
4170
+ lockAcquired = acquireAutoUpdateLock();
4171
+ if (!lockAcquired) {
4172
+ if (requireSuccess) {
4173
+ throw new Error('Auto-update lock is busy; unable to verify the latest remits-cli version before starting.');
4174
+ }
4175
+ return false;
4176
+ }
4177
+ const pkg = require(path.join(__dirname, 'package.json'));
4178
+ const currentVersion = pkg.version;
4179
+ const latest = execFileSync(npmCommand(), ['view', REMITS_CLI_PACKAGE_NAME, 'version'], {
4180
+ encoding: 'utf8',
4181
+ stdio: ['ignore', 'pipe', 'ignore']
4182
+ }).trim();
4183
+ if (latest && compareSemver(latest, currentVersion) > 0) {
4184
+ console.log('[update] New version available: ' + currentVersion + ' -> ' + latest + '. Installing...');
4185
+ const install = spawnSync(npmCommand(), ['install', '-g', REMITS_CLI_PACKAGE_NAME + '@latest'], {
4186
+ stdio: 'inherit',
4187
+ env: process.env
4188
+ });
4189
+ if (install.error || install.status !== 0) {
4190
+ if (requireSuccess) {
4191
+ throw new Error('Auto-update failed; remits-cli start requires the latest published version before continuing.');
4192
+ }
4193
+ console.log('[update] Update failed; continuing with version ' + currentVersion + '.');
4194
+ return false;
4195
+ }
4196
+ console.log('[update] Updated to ' + latest + '. Re-running command...');
4197
+ const rerun = spawnSync(remitsCliCommand(), originalArgv, {
4198
+ stdio: 'inherit',
4199
+ env: Object.assign({}, process.env, { REMITS_CLI_AUTO_UPDATED: '1' })
4200
+ });
4201
+ if (rerun.error) {
4202
+ if (requireSuccess) {
4203
+ throw new Error('Auto-update succeeded but re-running the updated remits-cli command failed.');
4204
+ }
4205
+ console.log('[update] Re-run failed; continuing with version ' + currentVersion + '.');
4206
+ return false;
4207
+ }
4208
+ process.exitCode = rerun.status === null ? 1 : rerun.status;
4209
+ return true;
4210
+ }
4211
+ return false;
4212
+ } catch (err) {
4213
+ if (requireSuccess) {
4214
+ throw err;
4215
+ }
4216
+ // Best-effort: network, npm, and permission failures should not block CLI usage.
4217
+ return false;
4218
+ } finally {
4219
+ if (lockAcquired) releaseAutoUpdateLock();
4220
+ }
4221
+ }
4222
+
4223
+ async function main() {
4224
+ migrateSessionIfNeeded();
4225
+ const originalArgv = process.argv.slice(2);
4226
+ const args = parseArgs(originalArgv);
4227
+ const [command, subcommand] = args._;
4228
+
4229
+ if (shouldAutoUpdate(command, args)) {
4230
+ const requireSuccessfulAutoUpdate = command === 'start';
4231
+ if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
4232
+ }
4233
+
4234
+ // Best-effort: update account-repo index whenever running from inside a remits repo
4235
+ try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
4236
+
4237
+ // Auto-start the background service if not already running.
4238
+ // Skip for lifecycle subcommands and help.
4239
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status';
4240
+ const isHelpCmd = !command || command === 'help' || command === '--help';
4241
+ if (!isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
4242
+ try {
4243
+ const dashboardUrl = await autoStartServiceIfNeeded();
4244
+ if (dashboardUrl) {
4245
+ console.log('[service] Auto-started in background');
4246
+ console.log('[service] Dashboard:', dashboardUrl);
4247
+ }
4248
+ } catch (_) { /* best-effort */ }
4249
+ }
4250
+
4251
+ // Guides are no longer bundled with the CLI. For account-work commands, make
4252
+ // sure the repo has the latest guides — authenticating first if needed — so
4253
+ // only platform-authenticated users ever receive them. `auth` syncs its own
4254
+ // guides, so it is excluded here.
4255
+ const GUIDE_SYNC_COMMANDS = new Set(['components', 'test', 'token', 'tools', 'tool']);
4256
+ if (GUIDE_SYNC_COMMANDS.has(command)) {
4257
+ try { await ensureGuidesSynced(process.cwd(), args); } catch (_) { /* best-effort */ }
4258
+ }
4259
+
4260
+ if (!command || command === 'help' || command === '--help') {
4261
+ console.log('Usage: remits-cli <command>');
4262
+ console.log('Global options: --no-auto-update');
4263
+ console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
4264
+ console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
4265
+ console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
4266
+ console.log(' remits-cli start [--foreground true] [--port 8787]');
4267
+ console.log(' remits-cli stop');
4268
+ console.log(' remits-cli status');
4269
+ console.log(' remits-cli listen [stop|status] [--foreground true] # compatibility alias');
4270
+ console.log(' remits-cli data-mode [set test|prod]');
4271
+ console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
4272
+ console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--data-mode test|prod]');
4273
+ console.log(' remits-cli tool --name <toolName> [--base-url URL] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod]');
4274
+ console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod]');
4275
+ console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod]');
4276
+ console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod]');
4277
+ console.log(' remits-cli test run --test <id|name> [--base-url URL] [--names name1,name2] [--watch true|false] [--data-mode test|prod]');
4278
+ console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod]');
4279
+ process.exit(0);
4280
+ }
4281
+
4282
+ if (command === 'auth') {
4283
+ await authCommand(args);
4284
+ return;
4285
+ }
4286
+
4287
+ if (command === 'components' && (subcommand === 'push' || subcommand === 'stage')) {
4288
+ if (subcommand === 'push' && !args.mode) {
4289
+ args.mode = 'push';
4290
+ }
4291
+ await pushComponentsCommand(args);
4292
+ return;
4293
+ }
4294
+
4295
+ if (command === 'components' && subcommand === 'sync') {
4296
+ await syncComponentsCommand(args);
4297
+ return;
4298
+ }
4299
+
4300
+ if (command === 'components' && subcommand === 'commit') {
4301
+ await commitComponentsCommand(args);
4302
+ return;
4303
+ }
4304
+
4305
+ if (command === 'data-mode' || command === 'datamode' || command === 'data-model' || command === 'datamodel') {
4306
+ await dataModeCommand(args, subcommand);
4307
+ return;
4308
+ }
4309
+
4310
+ if (command === 'sessions') {
4311
+ await sessionsCommand(args, subcommand);
4312
+ return;
4313
+ }
4314
+
4315
+ if (command === 'config') {
4316
+ await configCommand(args, subcommand);
4317
+ return;
4318
+ }
4319
+
4320
+ if (command === 'listen') {
4321
+ if (subcommand === 'stop') {
4322
+ await listenStopCommand();
4323
+ return;
4324
+ }
4325
+ if (subcommand === 'status') {
4326
+ await listenStatusCommand();
4327
+ return;
4328
+ }
4329
+ await listenCommand(args);
4330
+ return;
4331
+ }
4332
+
4333
+ if (command === 'start') {
4334
+ await listenCommand(args);
4335
+ return;
4336
+ }
4337
+
4338
+ if (command === 'stop') {
4339
+ await listenStopCommand();
4340
+ return;
4341
+ }
4342
+
4343
+ if (command === 'status') {
4344
+ await listenStatusCommand();
4345
+ return;
4346
+ }
4347
+
4348
+ if (command === 'install') {
4349
+ await installSkillsCommand(args);
4350
+ return;
4351
+ }
4352
+
4353
+ if (command === 'test' && subcommand === 'run') {
4354
+ await testCommand(args);
4355
+ return;
4356
+ }
4357
+
4358
+ if (command === 'token') {
4359
+ await tokenCommand(args);
4360
+ return;
4361
+ }
4362
+
4363
+ if (command === 'tools') {
4364
+ await toolsCommand(args);
4365
+ return;
4366
+ }
4367
+
4368
+ if (command === 'tool') {
4369
+ await toolCommand(args);
4370
+ return;
4371
+ }
4372
+
4373
+ throw new Error('Unknown command: ' + args._.join(' '));
4374
+ }
4375
+
4376
+ main().catch((err) => {
4377
+ console.error('remits-cli error:', describeError(err) + sandboxNetworkHint(err));
4378
+ process.exit(1);
4379
+ });