@remits/remits-cli 0.1.104 → 0.1.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -29,6 +29,7 @@ const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
29
29
  const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
30
30
  const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
31
31
  const WEBSOCKET_STATE_FILE = path.join(SESSION_DIR, 'websocket-state.json');
32
+ const LISTENER_REGISTRATION_STATE_FILE = path.join(SESSION_DIR, 'listener-registration-state.json');
32
33
  const AUTO_UPDATE_LOCK_DIR = path.join(SESSION_DIR, 'auto-update.lock');
33
34
  const AUTO_UPDATE_DISABLED_VALUES = new Set(['0', 'false', 'no', 'off']);
34
35
  const REMITS_CLI_PACKAGE_NAME = '@remits/remits-cli';
@@ -45,6 +46,10 @@ const WEBSOCKET_RECONNECT_DELAY_MS = Number(process.env.REMITS_CLI_WEBSOCKET_REC
45
46
  const WEBSOCKET_HEARTBEAT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_HEARTBEAT_MS || 10000);
46
47
  const WEBSOCKET_CONNECTION_TIMEOUT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_CONNECTION_TIMEOUT_MS || 15000);
47
48
  const WEBSOCKET_WATCHDOG_GRACE_MS = Number(process.env.REMITS_CLI_WEBSOCKET_WATCHDOG_GRACE_MS || 30000);
49
+ const SUPPORT_TICKET_DASHBOARD_REFRESH_MS = Number(process.env.REMITS_CLI_SUPPORT_TICKET_REFRESH_MS || 30000);
50
+ const SUPPORT_TICKET_DASHBOARD_LIMIT = Number(process.env.REMITS_CLI_SUPPORT_TICKET_LIMIT || 20);
51
+ const LISTENER_REGISTRATION_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_INTERVAL_MS || 5 * 60 * 1000);
52
+ const LISTENER_REGISTRATION_MIN_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_MIN_INTERVAL_MS || 5 * 60 * 1000);
48
53
  const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
49
54
  '.git',
50
55
  '.gradle',
@@ -74,7 +79,7 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
74
79
 
75
80
  // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
76
81
  // flag keeps last-wins so existing callers are unaffected.
77
- const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved']);
82
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
78
83
 
79
84
  function parseArgs(argv) {
80
85
  const out = { _: [] };
@@ -1273,6 +1278,156 @@ function discoverAccountRepos() {
1273
1278
  };
1274
1279
  }
1275
1280
 
1281
+ function listenerRegistrationRepoAccounts() {
1282
+ return listAccountRepoEntries(readAccountRepoIndex())
1283
+ .sort((a, b) => Number(a.accountId) - Number(b.accountId))
1284
+ .map((entry) => ({
1285
+ accountId: Number(entry.accountId),
1286
+ name: entry.name || null,
1287
+ type: entry.type || null,
1288
+ platformAccountId: entry.platformAccountId || null,
1289
+ routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
1290
+ ? 'self_and_descendants'
1291
+ : 'self',
1292
+ source: entry.source || null,
1293
+ updatedAt: entry.updatedAt || null
1294
+ }));
1295
+ }
1296
+
1297
+ function readListenerRegistrationState() {
1298
+ if (!fs.existsSync(LISTENER_REGISTRATION_STATE_FILE)) {
1299
+ return {};
1300
+ }
1301
+ try {
1302
+ const data = JSON.parse(fs.readFileSync(LISTENER_REGISTRATION_STATE_FILE, 'utf8'));
1303
+ return data && typeof data === 'object' ? data : {};
1304
+ } catch (_) {
1305
+ return {};
1306
+ }
1307
+ }
1308
+
1309
+ function writeListenerRegistrationState(data) {
1310
+ ensureSessionDir();
1311
+ atomicWriteFile(LISTENER_REGISTRATION_STATE_FILE, JSON.stringify(data || {}, null, 2));
1312
+ }
1313
+
1314
+ function listenerRegistrationStateKey(session) {
1315
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
1316
+ const userId = session.user && session.user.id ? session.user.id : 'unknown';
1317
+ return baseUrl + '|' + String(userId);
1318
+ }
1319
+
1320
+ function serviceRegistrationState() {
1321
+ const state = readServiceState();
1322
+ return {
1323
+ running: isListenerRunning(),
1324
+ pid: state && state.pid ? state.pid : (fs.existsSync(LISTENER_PID_FILE) ? fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim() : null),
1325
+ startedAt: state && state.startedAt ? state.startedAt : runtimeState.service.startedAt,
1326
+ dashboardUrl: state && state.dashboardUrl ? state.dashboardUrl : runtimeState.service.dashboardUrl
1327
+ };
1328
+ }
1329
+
1330
+ function listenerRegistrationPayload(session, options = {}) {
1331
+ const service = serviceRegistrationState();
1332
+ return {
1333
+ client: 'remits-cli',
1334
+ reason: options.reason || 'heartbeat',
1335
+ baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
1336
+ accountId: Number(session.accountId),
1337
+ dataMode: normalizeDataMode(session.dataMode),
1338
+ websocketTopic: session.websocketTopic || (session.user && session.user.uuid ? String(session.user.uuid).replace(/-/g, '') : null),
1339
+ dashboardUrl: service.dashboardUrl || null,
1340
+ agent: getPreferredAgent(),
1341
+ service,
1342
+ repoAccounts: listenerRegistrationRepoAccounts()
1343
+ };
1344
+ }
1345
+
1346
+ function listenerRegistrationSignature(payload) {
1347
+ return sha256(stableStringify({
1348
+ baseUrl: payload.baseUrl,
1349
+ accountId: payload.accountId,
1350
+ dataMode: payload.dataMode,
1351
+ websocketTopic: payload.websocketTopic,
1352
+ dashboardUrl: payload.dashboardUrl,
1353
+ agent: payload.agent,
1354
+ serviceRunning: payload.service && payload.service.running,
1355
+ repoAccounts: (payload.repoAccounts || []).map((entry) => ({
1356
+ accountId: entry.accountId,
1357
+ type: entry.type,
1358
+ routingScope: entry.routingScope,
1359
+ updatedAt: entry.updatedAt
1360
+ }))
1361
+ }));
1362
+ }
1363
+
1364
+ function shouldRegisterCliListener(session, payload, options = {}) {
1365
+ if (options.force === true) {
1366
+ return true;
1367
+ }
1368
+ const state = readListenerRegistrationState();
1369
+ const key = listenerRegistrationStateKey(session);
1370
+ const existing = state[key];
1371
+ if (!existing) {
1372
+ return true;
1373
+ }
1374
+ const lastRegisteredAtMs = Number(existing.lastRegisteredAtMs || 0);
1375
+ if (!lastRegisteredAtMs || Date.now() - lastRegisteredAtMs >= LISTENER_REGISTRATION_MIN_INTERVAL_MS) {
1376
+ return true;
1377
+ }
1378
+ return existing.signature !== listenerRegistrationSignature(payload);
1379
+ }
1380
+
1381
+ function rememberCliListenerRegistration(session, payload) {
1382
+ const state = readListenerRegistrationState();
1383
+ const key = listenerRegistrationStateKey(session);
1384
+ state[key] = {
1385
+ baseUrl: payload.baseUrl,
1386
+ userId: session.user && session.user.id ? session.user.id : null,
1387
+ signature: listenerRegistrationSignature(payload),
1388
+ accountCount: Array.isArray(payload.repoAccounts) ? payload.repoAccounts.length : 0,
1389
+ lastRegisteredAt: new Date().toISOString(),
1390
+ lastRegisteredAtMs: Date.now()
1391
+ };
1392
+ writeListenerRegistrationState(state);
1393
+ }
1394
+
1395
+ async function registerCliListenerSession(session, options = {}) {
1396
+ if (!session || !session.token) {
1397
+ return null;
1398
+ }
1399
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
1400
+ const payload = listenerRegistrationPayload(session, options);
1401
+ if (!shouldRegisterCliListener(session, payload, options)) {
1402
+ return { success: true, skipped: true, reason: 'recently_registered' };
1403
+ }
1404
+ const api = buildAxios(baseUrl, session.token, parsePositiveInt(options.timeoutMs, 5000));
1405
+ const response = await api.post('/cli/register', payload);
1406
+ rememberCliListenerRegistration(session, payload);
1407
+ return response.data;
1408
+ }
1409
+
1410
+ async function bestEffortRegisterCliListeners(options = {}) {
1411
+ const sessions = listSessionsFromStore(readAllSessions()).filter((session) => session && session.token);
1412
+ if (!sessions.length) {
1413
+ return [];
1414
+ }
1415
+ const results = await Promise.all(sessions.map(async (session) => {
1416
+ try {
1417
+ const data = await registerCliListenerSession(session, options);
1418
+ return { success: true, accountId: session.accountId, baseUrl: session.baseUrl, data };
1419
+ } catch (err) {
1420
+ appendGlobalActivityLog('listener', 'registration.failed', {
1421
+ accountId: session.accountId,
1422
+ baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
1423
+ error: describeError(err)
1424
+ }, 'error');
1425
+ return { success: false, accountId: session.accountId, baseUrl: session.baseUrl, error: describeError(err) };
1426
+ }
1427
+ }));
1428
+ return results;
1429
+ }
1430
+
1276
1431
  function resolvePreferredAccountId(cwd, flags, session) {
1277
1432
  // Explicit --account-id wins over the repo's account-info.json so users can
1278
1433
  // target a child account (e.g. a customer) from a parent-account repo.
@@ -1572,6 +1727,42 @@ function changedComponentsFromWorkingTree(cwd) {
1572
1727
  });
1573
1728
  }
1574
1729
 
1730
+ /**
1731
+ * Components touched by commits between `ref` and HEAD.
1732
+ *
1733
+ * `changedComponentsFromWorkingTree` reads `git status`, i.e. UNCOMMITTED edits only — which is empty
1734
+ * at exactly the moment `--changed-only` is meant to run, because the documented flow is
1735
+ * commit -> push -> sync (sync reads the pushed remote, so it cannot see uncommitted work at all).
1736
+ * The gate was therefore unusable in the flow it exists to guard: it refused every plan with
1737
+ * "plan touches N component(s) this working tree did not edit". `--changed-since <ref>` supplies the
1738
+ * base so the same guard works after the commit.
1739
+ */
1740
+ function changedComponentsSinceRef(cwd, ref) {
1741
+ let output = '';
1742
+ try {
1743
+ output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components', {
1744
+ cwd,
1745
+ stdio: ['ignore', 'pipe', 'pipe']
1746
+ }).toString();
1747
+ } catch (_) {
1748
+ return null;
1749
+ }
1750
+ const byKey = new Map();
1751
+ for (const rawLine of output.split(/\r?\n/)) {
1752
+ const filePath = rawLine.trim();
1753
+ if (!filePath) continue;
1754
+ const info = componentPathInfo(cwd, filePath);
1755
+ if (!info) continue;
1756
+ if (!byKey.has(info.key)) {
1757
+ byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
1758
+ }
1759
+ const entry = byKey.get(info.key);
1760
+ if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
1761
+ if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
1762
+ }
1763
+ return Array.from(byKey.values());
1764
+ }
1765
+
1575
1766
  function parsePositiveInt(value, fallback) {
1576
1767
  const parsed = Number(value);
1577
1768
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
@@ -1951,6 +2142,9 @@ async function authCommand(flags) {
1951
2142
  });
1952
2143
  console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
1953
2144
  console.log('Data mode:', session.dataMode);
2145
+ try {
2146
+ await bestEffortRegisterCliListeners({ cwd, reason: 'auth' });
2147
+ } catch (_) { /* best-effort */ }
1954
2148
 
1955
2149
  try {
1956
2150
  const branchName = currentBranch(cwd);
@@ -2309,7 +2503,7 @@ async function syncComponentsCommand(flags) {
2309
2503
  throw new Error(previewResponse.message || 'Server sync dry-run failed');
2310
2504
  }
2311
2505
 
2312
- preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree);
2506
+ preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
2313
2507
 
2314
2508
  if (namesOnly) {
2315
2509
  printSessionResolutionWarning(sessionContext);
@@ -2332,7 +2526,7 @@ async function syncComponentsCommand(flags) {
2332
2526
  }
2333
2527
 
2334
2528
  const summary = buildSyncSummary(response);
2335
- const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree);
2529
+ const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
2336
2530
  summary.gates = gate.checks;
2337
2531
 
2338
2532
  if (flagEnabled(flags.json)) {
@@ -2505,7 +2699,7 @@ function parseExpectedRemoved(flags) {
2505
2699
  }
2506
2700
 
2507
2701
  // Fail-closed gates. Each returns a violation string or null; the command exits non-zero if any fire.
2508
- function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2702
+ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
2509
2703
  const entries = syncPlanEntries(response);
2510
2704
  const results = ((response && response.sync) || {}).syncResults || {};
2511
2705
  const removed = entries.filter((e) => e.bucket === 'removed');
@@ -2536,13 +2730,19 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2536
2730
  }
2537
2731
 
2538
2732
  if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
2539
- if (changedFromWorkingTree === null) {
2733
+ const since = flags['changed-since'] || flags.changedSince;
2734
+ const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
2735
+ if (since && fromRef === null) {
2736
+ violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
2737
+ checks.changedOnly = false;
2738
+ } else if (changedFromWorkingTree === null && fromRef === null) {
2540
2739
  violations.push('--changed-only: this checkout is not a git working tree, so the changed set cannot be established.');
2541
2740
  checks.changedOnly = false;
2542
2741
  } else {
2742
+ const changedSet = (changedFromWorkingTree || []).concat(fromRef || []);
2543
2743
  // A component the checkout edited is identified by type + id, or type + name for `new_` files.
2544
- const allowedIds = new Set(changedFromWorkingTree.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2545
- const allowedNames = new Set(changedFromWorkingTree.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2744
+ const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2745
+ const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2546
2746
  const unexpected = entries.filter((entry) => {
2547
2747
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
2548
2748
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
@@ -2550,8 +2750,15 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2550
2750
  });
2551
2751
  checks.changedOnly = unexpected.length === 0;
2552
2752
  if (unexpected.length) {
2553
- violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this working tree did not edit: ' +
2554
- unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : ''));
2753
+ // Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
2754
+ // plan is the ordinary post-commit state, not evidence that the plan is dangerous.
2755
+ const hint = (!changedSet.length && !since)
2756
+ ? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
2757
+ 'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
2758
+ 'from, or origin/main) so the gate can see committed work.'
2759
+ : '';
2760
+ violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
2761
+ unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
2555
2762
  }
2556
2763
  }
2557
2764
  }
@@ -2571,6 +2778,36 @@ function buildSyncSummary(response) {
2571
2778
  const errors = Array.isArray(results.errors) ? results.errors : [];
2572
2779
  const skipped = Array.isArray(results.skipped) ? results.skipped : [];
2573
2780
 
2781
+ // A sync that SHORT-CIRCUITED on the cached branch SHA did no work at all, and the server says so
2782
+ // (`skipped: true` plus a message). Rendering it through the normal shape below printed
2783
+ // `overridden: 0, added: 0, removed: []` — which is what a sync that ran and found nothing to do
2784
+ // prints, so the two were indistinguishable. That matters because the cache can hold a SHA whose
2785
+ // overlays were since removed by another path: an ordinary re-sync then reports zeros forever and
2786
+ // the operator has no reason to suspect the branch was never re-read. Report the skip as itself.
2787
+ // `syncResults` absent is the fallback signal, so this still reads correctly against a server that
2788
+ // predates the explicit `skipped` flag rather than silently falling back to the misleading zeros.
2789
+ const shortCircuited = sync.skipped === true
2790
+ || (sync.syncResults == null && !!sync.message && sync.dryRun !== true);
2791
+ if (shortCircuited) {
2792
+ return {
2793
+ success: response && response.success === true,
2794
+ accountId: response && response.accountId,
2795
+ branchName: sync.branchName || (response && response.branchName),
2796
+ mode: sync.mode || 'variant',
2797
+ skipped: true,
2798
+ reason: sync.message || 'branch head matches the cached sync SHA; the branch was not re-read',
2799
+ branchHeadSha: sync.branchHeadSha || sync.postSyncSha || null,
2800
+ warnings: [
2801
+ // Scoped to COMPONENTS deliberately: the short-circuit still refreshes the delivered guides and
2802
+ // account metadata, so "nothing happened" would be its own small untruth.
2803
+ 'NO COMPONENTS WERE SYNCED. This is the cached-SHA short-circuit, not an empty plan — the ' +
2804
+ 'branch was never re-read.',
2805
+ 'Use --dry-run to see the real plan (a dry run always re-reads the branch), or ' +
2806
+ '--force-tombstones to re-read and apply it.'
2807
+ ]
2808
+ };
2809
+ }
2810
+
2574
2811
  // A TRUNK sync and a VARIANT sync report in different vocabularies, and this summary only ever spoke
2575
2812
  // variant. So the single most consequential line of a promotion — "component X was CREATED on trunk as
2576
2813
  // id N" — printed as `added: 0`, and the runbook's definition of done ("components created, with their
@@ -3153,7 +3390,26 @@ async function testCommand(flags) {
3153
3390
  throw new Error('Missing --test <id-or-name>');
3154
3391
  }
3155
3392
 
3156
- const names = flags.names ? String(flags.names).split(',').map((s) => s.trim()).filter(Boolean) : [];
3393
+ // Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
3394
+ // therefore split a legitimate name in half, matched nothing, and (before the server learned to
3395
+ // report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
3396
+ // comma still works when no `|` is present, so existing invocations keep behaving as before.
3397
+ // `--names` may also be repeated, which needs no delimiter at all.
3398
+ const rawNames = flags.names === undefined || flags.names === null
3399
+ ? []
3400
+ : (Array.isArray(flags.names) ? flags.names : [flags.names]);
3401
+ const repeated = rawNames.length > 1;
3402
+ const names = rawNames
3403
+ .flatMap((value) => {
3404
+ const text = String(value);
3405
+ if (text.includes('|')) return text.split('|');
3406
+ // Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
3407
+ // ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
3408
+ // would take a deliberate literal name back apart.
3409
+ return repeated ? [text] : text.split(',');
3410
+ })
3411
+ .map((s) => s.trim())
3412
+ .filter(Boolean);
3157
3413
 
3158
3414
  const api = buildAxios(baseUrl, session.token);
3159
3415
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -3209,6 +3465,23 @@ async function testCommand(flags) {
3209
3465
  }
3210
3466
 
3211
3467
  console.log('Final status:', JSON.stringify(status, null, 2));
3468
+
3469
+ // A selector that matched no case is a mis-specified run, not a passing one. Say so in the terminal
3470
+ // and exit non-zero, or "0 passed, 0 failed" reads exactly like a suite where everything passed.
3471
+ const unmatched = (status.result && status.result.unmatchedTestNames) || [];
3472
+ if (unmatched.length) {
3473
+ console.error('');
3474
+ console.error('No test case matched: ' + unmatched.map((n) => '"' + n + '"').join(', '));
3475
+ const declared = (status.result && status.result.declaredTestNames) || [];
3476
+ if (declared.length) {
3477
+ console.error('Cases declared by this suite:');
3478
+ declared.forEach((n) => console.error(' - ' + n));
3479
+ }
3480
+ console.error('--names is delimited by "|" (a comma still works when no "|" is present), so a case');
3481
+ console.error('name containing a comma must be passed with "|" or by repeating --names.');
3482
+ process.exitCode = 1;
3483
+ }
3484
+
3212
3485
  if (status.status !== 'completed') {
3213
3486
  process.exitCode = 1;
3214
3487
  } else if (status.result && status.result.failed > 0) {
@@ -3912,6 +4185,10 @@ let runtimeState = {
3912
4185
  websocket: {},
3913
4186
  lastAction: null
3914
4187
  };
4188
+ let supportTicketSnapshotCache = {
4189
+ fetchedAtMs: 0,
4190
+ value: null
4191
+ };
3915
4192
 
3916
4193
  function listenerSessionEntriesFromStore(store) {
3917
4194
  return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
@@ -4299,6 +4576,7 @@ function exportIssueBundle(snapshot) {
4299
4576
  copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
4300
4577
  copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
4301
4578
  copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
4579
+ copyFileIfExists(LISTENER_REGISTRATION_STATE_FILE, path.join(globalDir, 'listener-registration-state.json'));
4302
4580
  copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
4303
4581
  copyFileIfExists(DISPATCH_STATE_FILE, path.join(globalDir, 'dispatch-panes.json'));
4304
4582
  copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
@@ -4340,6 +4618,7 @@ function buildGlobalFileSnapshot() {
4340
4618
  { label: 'config.json', path: CONFIG_FILE, mode: 'json' },
4341
4619
  { label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
4342
4620
  { label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
4621
+ { label: 'listener-registration-state.json', path: LISTENER_REGISTRATION_STATE_FILE, mode: 'json' },
4343
4622
  { label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
4344
4623
  { label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
4345
4624
  { label: 'tmux-activity.log (tail)', path: TMUX_ACTIVITY_LOG_FILE, mode: 'tail' }
@@ -4396,7 +4675,140 @@ function buildRepoSnapshot(entry) {
4396
4675
  };
4397
4676
  }
4398
4677
 
4399
- function collectDashboardSnapshot() {
4678
+ function compactSupportTicketRow(row) {
4679
+ if (!row || typeof row !== 'object') {
4680
+ return null;
4681
+ }
4682
+ return {
4683
+ ticketId: row.ticketId || row.id || null,
4684
+ accountId: row.accountId || row.account_id || null,
4685
+ accountName: row.accountName || null,
4686
+ subject: row.subject || null,
4687
+ priority: row.priority || null,
4688
+ status: row.status || null,
4689
+ type: row.type || null,
4690
+ source: row.source || null,
4691
+ assignedTo: row.assignedTo || null,
4692
+ implementationAccountId: row.implementationAccountId || null,
4693
+ implementationAccountName: row.implementationAccountName || null,
4694
+ affectedComponent: row.affectedComponent || null,
4695
+ updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
4696
+ activityAt: row.activityAt || row.lastActivityAt || null,
4697
+ activityLabel: row.activityLabel || null
4698
+ };
4699
+ }
4700
+
4701
+ function supportTicketQueueInput(session) {
4702
+ return {
4703
+ action: 'list',
4704
+ accountId: Number(session.accountId),
4705
+ includeChildren: true,
4706
+ includeRoot: true,
4707
+ sortBy: 'triage',
4708
+ limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
4709
+ };
4710
+ }
4711
+
4712
+ async function fetchSupportTicketQueueForSession(session) {
4713
+ const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4714
+ const api = buildAxios(baseUrl, session.token, 12000);
4715
+ const response = await api.post('/cli/tool', {
4716
+ token: session.token,
4717
+ accountId: Number(session.accountId),
4718
+ branchName: '',
4719
+ dataMode: 'prod',
4720
+ name: 'mcp_support_ticket_queue',
4721
+ callId: 'dashboard-support-ticket-queue-' + crypto.randomUUID(),
4722
+ input: supportTicketQueueInput(session)
4723
+ }).then((r) => r.data);
4724
+
4725
+ const result = response && response.result ? response.result : {};
4726
+ const tickets = Array.isArray(result.tickets)
4727
+ ? result.tickets.map(compactSupportTicketRow).filter(Boolean)
4728
+ : [];
4729
+ return {
4730
+ success: response && response.success !== false && response.toolSuccess !== false && result.success !== false,
4731
+ baseUrl,
4732
+ accountId: Number(session.accountId),
4733
+ userId: session.user && session.user.id ? session.user.id : null,
4734
+ dataMode: result.dataMode || response.dataMode || 'prod',
4735
+ totalCount: result.totalCount || tickets.length,
4736
+ count: result.count || tickets.length,
4737
+ counts: result.counts || {},
4738
+ recommendedTicket: compactSupportTicketRow(result.recommendedTicket),
4739
+ tickets,
4740
+ searchedAccounts: result.searchedAccounts || [],
4741
+ message: response.toolMessage || result.message || null
4742
+ };
4743
+ }
4744
+
4745
+ async function collectSupportTicketSnapshot() {
4746
+ const nowMs = Date.now();
4747
+ if (supportTicketSnapshotCache.value &&
4748
+ nowMs - supportTicketSnapshotCache.fetchedAtMs < SUPPORT_TICKET_DASHBOARD_REFRESH_MS) {
4749
+ return supportTicketSnapshotCache.value;
4750
+ }
4751
+
4752
+ const sessions = listSessionsFromStore(readAllSessions())
4753
+ .filter((session) => session && session.token && session.accountId);
4754
+ const seen = new Set();
4755
+ const queueSessions = sessions.filter((session) => {
4756
+ const key = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL) + '|' + String(session.accountId) + '|' +
4757
+ String(session.user && session.user.id ? session.user.id : '');
4758
+ if (seen.has(key)) {
4759
+ return false;
4760
+ }
4761
+ seen.add(key);
4762
+ return true;
4763
+ }).slice(0, 12);
4764
+
4765
+ const queues = [];
4766
+ const errors = [];
4767
+ await Promise.all(queueSessions.map(async (session) => {
4768
+ try {
4769
+ queues.push(await fetchSupportTicketQueueForSession(session));
4770
+ } catch (err) {
4771
+ errors.push({
4772
+ baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
4773
+ accountId: Number(session.accountId),
4774
+ userId: session.user && session.user.id ? session.user.id : null,
4775
+ message: describeError(err)
4776
+ });
4777
+ }
4778
+ }));
4779
+
4780
+ const ticketsById = {};
4781
+ for (const queue of queues) {
4782
+ for (const ticket of queue.tickets || []) {
4783
+ const key = String(ticket.ticketId || '');
4784
+ if (!key || ticketsById[key]) {
4785
+ continue;
4786
+ }
4787
+ ticketsById[key] = {
4788
+ ...ticket,
4789
+ queueAccountId: queue.accountId,
4790
+ baseUrl: queue.baseUrl
4791
+ };
4792
+ }
4793
+ }
4794
+
4795
+ const value = {
4796
+ fetchedAt: new Date(nowMs).toISOString(),
4797
+ refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4798
+ queueCount: queues.length,
4799
+ totalCount: queues.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0),
4800
+ tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
4801
+ queues,
4802
+ errors
4803
+ };
4804
+ supportTicketSnapshotCache = {
4805
+ fetchedAtMs: nowMs,
4806
+ value
4807
+ };
4808
+ return value;
4809
+ }
4810
+
4811
+ async function collectDashboardSnapshot() {
4400
4812
  const accountRepoIndex = readAccountRepoIndex();
4401
4813
  const repoEntries = listAccountRepoEntries(accountRepoIndex)
4402
4814
  .sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
@@ -4451,6 +4863,7 @@ function collectDashboardSnapshot() {
4451
4863
  globalFiles,
4452
4864
  repos: repoEntries,
4453
4865
  platformRepo,
4866
+ supportTickets: await collectSupportTicketSnapshot(),
4454
4867
  issues,
4455
4868
  lastAction: runtimeState.lastAction
4456
4869
  };
@@ -4506,6 +4919,9 @@ function renderDashboardHtml(snapshot) {
4506
4919
  '<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
4507
4920
  '</section>' +
4508
4921
  '</div>' +
4922
+ '<div class="section-grid">' +
4923
+ '<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
4924
+ '</div>' +
4509
4925
  '<div class="section-grid">' +
4510
4926
  '<section class="panel"><h2>Issues & Escalation</h2><div id="issues-section" class="accordion"></div></section>' +
4511
4927
  '</div>' +
@@ -4536,9 +4952,10 @@ function renderDashboardHtml(snapshot) {
4536
4952
  'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
4537
4953
  'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
4538
4954
  '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>\';}' +
4539
- '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=[' +
4955
+ 'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const tmuxHealthy=state.tmux&&state.tmux.available&&state.tmux.sessionExists;const cards=[' +
4540
4956
  '{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")},' +
4541
4957
  '{title:"Websockets",value:String(connectedCount)+"/"+String(wsValues.length||0),label:"Connected topics groups",tone:errorCount? "bad" : (warnCount? "warn" : "ok"),sub: errorCount? String(errorCount)+" unhealthy connection(s)" : (warnCount? String(warnCount)+" connecting" : "All connections healthy")},' +
4958
+ '{title:"Tickets",value:String(ticketTotal),label:"Open support queue",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":"Fetched "+String(ticketState.queueCount||0)+" queue(s)"},' +
4542
4959
  '{title:"Tmux",value:tmuxHealthy?"Ready":"Attention",label:"Dispatch workspace",tone:tmuxHealthy?"ok":(state.tmux&&state.tmux.available?"warn":"bad"),sub:state.tmux&&state.tmux.available?(state.tmux.sessionExists?String((state.tmux.livePanes||[]).length)+" live pane(s)":"tmux installed, session missing"):"tmux not installed"},' +
4543
4960
  '{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"},' +
4544
4961
  '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
@@ -4549,15 +4966,18 @@ function renderDashboardHtml(snapshot) {
4549
4966
  'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
4550
4967
  'summaryItem("Connections",String(ws.length)),' +
4551
4968
  'summaryItem("Topics",String(topics)),' +
4969
+ 'summaryItem("Open Tickets",String(state.supportTickets&&state.supportTickets.totalCount||0)),' +
4552
4970
  'summaryItem("Tracked Panes",String(tracked))' +
4553
4971
  '].join("");}' +
4554
4972
  '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>\':"");}' +
4555
4973
  'function renderIssues(state){const target=document.getElementById("issues-section");const issues=state.issues||[];if(!issues.length){target.innerHTML=\'<div class="empty">No automatic issue signals are currently detected. If something still feels wrong, you can still click "Export Issue Bundle" to prepare a package for a Remits system admin.</div>\';return;}target.innerHTML=issues.map(function(issue,index){const tone=issue.severity==="bad"?"bad":"warn";const meta=badge(issue.severity==="bad"?"critical":"warning",tone)+\' <span class="muted">\'+escapeHtml(issue.scope+(issue.repoAccountId?(" account "+issue.repoAccountId):""))+\'</span>\';const evidence=(issue.evidence||[]).map(function(line){return "<li>"+escapeHtml(line)+"</li>";}).join("");const body=\'<div class="stack"><div class="muted">\'+escapeHtml(issue.summary||"")+\'</div><div class="mini-card"><strong>Recommendation</strong>\'+escapeHtml(issue.recommendation||"") + \'</div><div><strong style="display:block;margin-bottom:8px;">Evidence</strong><ul style="margin:0;padding-left:18px;display:grid;gap:8px;">\'+(evidence||"<li>No evidence lines captured.</li>")+\'</ul></div></div>\';return detailsBlock("issue:"+issue.id,(index+1)+". "+issue.title,meta,body,index===0);}).join("");}' +
4974
+ 'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Total Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Queues</strong>\'+escapeHtml(String(support.queueCount||0))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}const recs=queues.map(function(queue){return queue.recommendedTicket?Object.assign({queueAccountId:queue.accountId,baseUrl:queue.baseUrl},queue.recommendedTicket):null;}).filter(Boolean);if(recs.length){const recBody=recs.map(function(ticket,index){return supportTicketDetails(ticket,"support-rec:"+index);}).join("");html+=detailsBlock("support:recommended","Recommended next tickets",badge(String(recs.length)+" queue(s)","ok"),recBody,true);}if(tickets.length){html+=tickets.map(function(ticket,index){const meta=badge(ticket.priority||"priority","warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(ticket,"support-ticket:"+index),index===0&&!recs.length);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned by the current queues.</div>\';}target.innerHTML=html;}' +
4975
+ 'function supportTicketDetails(ticket,key){const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Assigned</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Activity</strong>\'+escapeHtml(ticket.activityLabel||formatTime(ticket.activityAt||ticket.updatedAt))+\'</div></div><div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
4556
4976
  '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("");}' +
4557
4977
  'function renderTmux(state){const target=document.getElementById("tmux-section");const panes=state.tmux&&state.tmux.livePanes||[];const trackedText=state.tmux&&state.tmux.trackedPanes?state.tmux.trackedPanes:"{}";const launchSupported=state.tmux&&state.tmux.terminalLaunchSupported;const attachCommand="tmux attach -t "+(state.tmux&&state.tmux.sessionName||"remits-listener");const health=\'<div class="mini-grid"><div class="mini-card"><strong>tmux Installed</strong>\'+escapeHtml(String(state.tmux&&state.tmux.available))+\'</div><div class="mini-card"><strong>Session Exists</strong>\'+escapeHtml(String(state.tmux&&state.tmux.sessionExists))+\'</div><div class="mini-card"><strong>Session Name</strong>\'+escapeHtml(state.tmux&&state.tmux.sessionName||"n/a")+\'</div><div class="mini-card"><strong>Live Panes</strong>\'+escapeHtml(String(panes.length))+\'</div><div class="mini-card"><strong>Open from Dashboard</strong>\'+escapeHtml(launchSupported?"supported":"not supported")+\'</div></div><div class="muted" style="margin-bottom:14px;">\'+escapeHtml(launchSupported?"Use the Open Tmux Terminal button to bring Terminal forward and attach automatically.":"Dashboard-to-terminal launch is not supported on this platform. Run the attach command manually.")+\'</div>\';const quickAttach=detailsBlock("tmux:attach","Quick attach command","manual fallback",codeBlock("tmux-attach-cmd",attachCommand),false);const paneSection=detailsBlock("tmux:panes","Live panes",escapeHtml(String(panes.length))+" pane(s)",codeBlock("tmux-live-panes",JSON.stringify(panes,null,2),"log"),false);const trackedSection=detailsBlock("tmux:tracked","Tracked pane routing","ticket/task to pane mapping",codeBlock("tmux-tracked",trackedText),false);const logFile=(state.globalFiles||[]).find(function(file){return file.label==="tmux-activity.log (tail)";});const logSection=detailsBlock("tmux:activity","Activity tail","stream of tmux and websocket events",codeBlock("tmux-log",logFile&&logFile.content||"[missing]","log"),true);target.innerHTML=health+quickAttach+paneSection+trackedSection+logSection;const openButton=document.querySelector(\'[data-action="open-tmux"]\');if(openButton){openButton.disabled=!launchSupported||!(state.tmux&&state.tmux.available);}}' +
4558
4978
  'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="tmux-activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
4559
4979
  '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("");}' +
4560
- '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);}' +
4980
+ 'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderTmux(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
4561
4981
  '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();}' +
4562
4982
  '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);}' +
4563
4983
  'async function triggerAction(action,button){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:"action="+encodeURIComponent(action)});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
@@ -4616,9 +5036,10 @@ function startDashboardServer(preferredPort) {
4616
5036
  const requestUrl = new URL(req.url, 'http://' + DASHBOARD_HOST + ':' + (runtimeState.service.dashboardPort || preferredPort));
4617
5037
  if (req.method === 'GET' && requestUrl.pathname === '/api/state') {
4618
5038
  try {
5039
+ const snapshot = await collectDashboardSnapshot();
4619
5040
  res.statusCode = 200;
4620
5041
  res.setHeader('Content-Type', 'application/json');
4621
- res.end(JSON.stringify(collectDashboardSnapshot(), null, 2));
5042
+ res.end(JSON.stringify(snapshot, null, 2));
4622
5043
  } catch (err) {
4623
5044
  sendDashboardError(res, err, true);
4624
5045
  }
@@ -4641,11 +5062,13 @@ function startDashboardServer(preferredPort) {
4641
5062
  at: new Date().toISOString()
4642
5063
  };
4643
5064
  if (action === 'export-issues') {
4644
- const bundlePath = exportIssueBundle(collectDashboardSnapshot());
5065
+ const bundlePath = exportIssueBundle(await collectDashboardSnapshot());
4645
5066
  actionResult = { bundlePath };
4646
5067
  runtimeState.lastAction.bundlePath = bundlePath;
4647
5068
  } else if (action === 'rescan-repos') {
4648
5069
  recordDiscoverySummary(discoverAccountRepos());
5070
+ supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
5071
+ await bestEffortRegisterCliListeners({ reason: 'dashboard.rescan' });
4649
5072
  } else if (action === 'open-tmux') {
4650
5073
  openTmuxSessionForUser();
4651
5074
  } else if (action === 'start-tmux') {
@@ -4662,7 +5085,7 @@ function startDashboardServer(preferredPort) {
4662
5085
  success: true,
4663
5086
  action,
4664
5087
  result: actionResult,
4665
- state: collectDashboardSnapshot()
5088
+ state: await collectDashboardSnapshot()
4666
5089
  }));
4667
5090
  return;
4668
5091
  }
@@ -4678,9 +5101,10 @@ function startDashboardServer(preferredPort) {
4678
5101
 
4679
5102
  if (req.method === 'GET' && requestUrl.pathname === '/') {
4680
5103
  try {
5104
+ const snapshot = await collectDashboardSnapshot();
4681
5105
  res.statusCode = 200;
4682
5106
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
4683
- res.end(renderDashboardHtml(collectDashboardSnapshot()));
5107
+ res.end(renderDashboardHtml(snapshot));
4684
5108
  } catch (err) {
4685
5109
  sendDashboardError(res, err, false);
4686
5110
  }
@@ -5209,14 +5633,15 @@ function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, user
5209
5633
  const acctId = payload.accountId || accountIds[0];
5210
5634
  console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
5211
5635
  }
5212
- } else if (data.type === 'remits-cli') {
5636
+ } else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
5213
5637
  const payload = data.payload || data;
5214
5638
  const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
5215
- console.log('[ws][remits-cli][account:' + dispatchAccountId + ']', JSON.stringify(payload));
5216
- appendGlobalActivityLog('tmux', 'dispatch.received', {
5639
+ console.log('[ws][listener][account:' + dispatchAccountId + ']', JSON.stringify(payload));
5640
+ appendGlobalActivityLog('tmux', 'listener_delivery.received', {
5217
5641
  baseUrl,
5218
5642
  topic,
5219
5643
  accountId: dispatchAccountId,
5644
+ messageType: data.type,
5220
5645
  source,
5221
5646
  messageId,
5222
5647
  message: summarizeDispatchMessage(payload)
@@ -5618,6 +6043,8 @@ async function listenCommand(flags) {
5618
6043
  recordDiscoverySummary(discoverAccountRepos());
5619
6044
  const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
5620
6045
  await startDashboardServer(dashboardPort);
6046
+ await bestEffortRegisterCliListeners({ reason: 'listener.start' });
6047
+ let lastListenerRegistrationAt = Date.now();
5621
6048
 
5622
6049
  // Create the tmux session immediately so the user can attach right away
5623
6050
  try {
@@ -5705,6 +6132,11 @@ async function listenCommand(flags) {
5705
6132
  activeSessionSignature = currentSignature;
5706
6133
  }
5707
6134
 
6135
+ if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
6136
+ await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
6137
+ lastListenerRegistrationAt = Date.now();
6138
+ }
6139
+
5708
6140
  await runManagedWebsocketWatchdog();
5709
6141
  updateRuntimeServiceState();
5710
6142
  } catch (err) {
@@ -5977,6 +6409,8 @@ function printComponentsHelp(subcommand) {
5977
6409
  console.log('');
5978
6410
  console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
5979
6411
  console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
6412
+ console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
6413
+ console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
5980
6414
  console.log(' --names-only dry-run first; print only "type id name" lines and write nothing');
5981
6415
  console.log(' --fail-on-removed dry-run first; fail if the plan removes/tombstones anything');
5982
6416
  console.log(' --fail-on-errors dry-run first; fail if the server reported any per-component sync error');
@@ -6037,7 +6471,7 @@ function printComponentsHelp(subcommand) {
6037
6471
  }
6038
6472
 
6039
6473
  function printTestHelp() {
6040
- console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6474
+ console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6041
6475
  console.log('');
6042
6476
  console.log('Runs a Test component against the staged/variant world for this checkout.');
6043
6477
  console.log('Examples:');
@@ -6045,7 +6479,8 @@ function printTestHelp() {
6045
6479
  console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
6046
6480
  console.log('');
6047
6481
  console.log('Notes:');
6048
- console.log(' --names is comma-delimited, so avoid commas in individual test case names.');
6482
+ console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
6483
+ console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
6049
6484
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
6050
6485
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
6051
6486
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -6095,6 +6530,9 @@ async function main() {
6095
6530
 
6096
6531
  // Best-effort: update account-repo index whenever running from inside a remits repo
6097
6532
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
6533
+ if (!wantsJson) {
6534
+ try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
6535
+ }
6098
6536
 
6099
6537
  // Auto-start the background service if not already running.
6100
6538
  // Skip for lifecycle subcommands and help.
@@ -6146,7 +6584,7 @@ async function main() {
6146
6584
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
6147
6585
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
6148
6586
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
6149
- console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
6587
+ console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
6150
6588
  console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6151
6589
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
6152
6590
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.104",
3
+ "version": "0.1.107",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -339,6 +339,14 @@ file is catastrophic.
339
339
  - Local branch is committed and pushed; sync will read the intended remote commit.
340
340
  - Local filenames and live inventory (`mcp_account_view`) **agree on id and name for every component**: no live
341
341
  component appears locally under a different id, and no expected component is missing a repo file.
342
+ > **Do not run this comparison against `account-info.json` alone — it will report false orphans.** That
343
+ > file omits `auxiliary: true` components by design, so every auxiliary component looks like a repo file
344
+ > with no DB row, i.e. exactly the "a trunk sync will CREATE a duplicate" signal this check exists to
345
+ > catch. It also omits README- and AGENT-purpose Prompts, which live at the repo root and as `.md`
346
+ > sidecars in `components/agents/` rather than in `components/prompts/`, so they look like DB rows with
347
+ > no repo file — the "will be DELETED" signal. Both are benign. Before treating a flagged component as
348
+ > drift, confirm against the live row: `mcp_component_view`, or
349
+ > `mcp_run_action controlAction:"describe"` for an Action. A component that answers is not an orphan.
342
350
  - You can state the expected create/update/delete set. **If any delete or renumber is unexpected, stop.**
343
351
 
344
352
  ### If something looks wrong — stop, don't paper over
@@ -895,7 +903,16 @@ Tests run on the platform against your staged snapshot. They stream results in r
895
903
 
896
904
  Important test-runner constraints:
897
905
  - `remits-cli test run` now defaults to `test` dataMode unless you explicitly pass `--data-mode prod`.
898
- - Do not put commas in individual `test("...")` names. The CLI `--names` filter is comma-delimited, so comma-bearing test names cannot be targeted cleanly as a single selected case.
906
+ - **`--names` is delimited by `|`, and may be repeated.** A comma still splits a single `--names` value
907
+ (legacy behaviour), which is why a case name containing a comma used to be cut in half and match
908
+ nothing. Prefer `|` or repetition whenever a name might contain punctuation:
909
+ ```bash
910
+ remits-cli test run --test 13 --names "a case, with a comma|another case"
911
+ remits-cli test run --test 13 --names "a case, with a comma" --names "another case"
912
+ ```
913
+ - **A selector that matches no case FAILS the run.** It used to report `0 passed, 0 failed`,
914
+ `completed`, and exit 0 — indistinguishable from a suite where everything passed. The run now names
915
+ the unmatched selectors and lists the cases the suite actually declared, and exits non-zero.
899
916
 
900
917
  If no relevant Test component exists yet, consider creating one. Test components live in `components/tests/` and follow the same component structure. They provide permanent regression protection — every test you write today saves debugging time tomorrow.
901
918
 
@@ -1617,6 +1634,15 @@ a confirm-gated override when the removal guard refuses. Preview there is the sa
1617
1634
  - A test/tool response reports `testComponentSource` as `staged` | `variant` | `db`, so you can see which
1618
1635
  layer the run resolved without reading logs.
1619
1636
 
1637
+ > **A populated staging cache makes a variant look broken.** Anything carrying a CLI `TestMode` — a
1638
+ > `remits-cli token` URL, `/s/<tokenKey>/...`, an `X-Auth-Token` request, a script-loader embed — resolves
1639
+ > the STAGED layer, which outranks the variant. So with components staged under the same branch/user, a
1640
+ > tokenized page reports `Component Branch: <branch>` but `Variant Applied: none` and renders trunk, while
1641
+ > an anonymous `?account_id=` request to the same page reports the variant. That is the documented
1642
+ > precedence (staged -> variant -> trunk) working correctly, and it reads exactly like "tokens break
1643
+ > variant resolution". **Run `remits-cli components clear --all` before verifying variant resolution
1644
+ > through any tokenized entry point**, or check `remits-cli components status` first.
1645
+
1620
1646
  ## Production Support Workflow
1621
1647
 
1622
1648
  Switch to prod mode for investigations:
@@ -2804,7 +2830,7 @@ remits-cli data-mode [set test|prod]
2804
2830
  remits-cli components stage [--branch <name>] [--data-mode test|prod] [--json|--verbose]
2805
2831
  remits-cli components status [--branch <name>] [--component-type <type>] [--component-id <id>] [--json|--verbose]
2806
2832
  remits-cli components clear [--branch <name>] [--component-type <type>] [--component-id <id>] [--all] [--json|--verbose] # id alone scopes to one component when unambiguous (ids are type-local; add --component-type if the same id is staged in multiple families); no filter clears the whole branch scope; --all forces the full wipe
2807
- remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2833
+ remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--changed-only [--changed-since <ref>]] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2808
2834
  remits-cli components commit [--message "msg"] [--data-mode test|prod] [--force-tombstones]
2809
2835
  remits-cli components branches [--json] # branches carrying committed variants, with counts + drift
2810
2836
  remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
@@ -2813,7 +2839,7 @@ remits-cli components branch <name> --subscribers [--json]
2813
2839
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
2814
2840
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
2815
2841
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
2816
- remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a,b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2842
+ remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2817
2843
  remits-cli token [--path <embeddablePathOrId>] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2818
2844
  remits-cli token inspect --token <token|tokenKey|URL> # inspect token metadata, safety/dataMode evidence, and full context
2819
2845
  remits-cli tools [--branch <name>] [--data-mode test|prod] [--variant-branch <name|none>]
@@ -2823,7 +2849,8 @@ remits-cli tool status --call-id <callId> [--data-mode test|prod]
2823
2849
 
2824
2850
  For tests specifically:
2825
2851
  - If `--data-mode` is omitted, `remits-cli test run` uses `test`.
2826
- - `--names` is comma-delimited, so keep individual test names comma-free.
2852
+ - `--names` is `|`-delimited (a comma still splits a single value) and may be repeated; an unmatched
2853
+ selector fails the run instead of reporting zero cases as success.
2827
2854
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
2828
2855
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
2829
2856
  (*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
@@ -2841,10 +2868,16 @@ For tests specifically:
2841
2868
  - **Fail-closed sync gates.** On non-trunk variant branches these flags now force a server dry-run first,
2842
2869
  evaluate that plan before any overlay row is written, and only then run the mutating sync when the plan
2843
2870
  passes. On trunk, there is no safe dry-run plan, so do not treat these as scoped commit controls:
2844
- - `--changed-only` — fail unless every planned write is a component **this checkout actually edited**.
2871
+ - `--changed-only` — fail unless every planned write is a component **this checkout actually changed**.
2845
2872
  This is the strongest guard against a sync that quietly rewrites components you never touched. It
2846
2873
  also fails when the checkout is not a git working tree, because "git could not answer" must never
2847
2874
  be read as "nothing changed".
2875
+ > **Pair it with `--changed-since <ref>` after you have committed.** On its own `--changed-only`
2876
+ > reads UNCOMMITTED edits, and the documented flow commits and pushes *before* syncing (sync reads
2877
+ > the pushed remote, so it cannot see uncommitted work at all) — so the changed set is empty at
2878
+ > exactly the moment the gate runs, and it refuses the whole plan. `--changed-since origin/main`
2879
+ > (or the commit you branched from) makes the changed set the components your commits touched.
2880
+ > The refusal message says this when it detects the empty-set case.
2848
2881
  - `--fail-on-removed` — fail if the plan removes or tombstones anything.
2849
2882
  - `--expected-removed <type:id>` — whitelist the removals you intend (repeatable, or comma-delimited,
2850
2883
  e.g. `--expected-removed action:5,reader:9`). It **implies** `--fail-on-removed`, so any removal you
@@ -2902,6 +2935,8 @@ For tests specifically:
2902
2935
  | A component vanished for one account after a variant-branch sync | Its file is missing from that branch, so the sync created a **tombstone** that hides it from subscribers. Restore the file on the branch and re-sync. Trunk is unaffected. |
2903
2936
  | A variant Test suite fails wholesale, asserting trunk where you expect a variant | You are almost certainly running it from a **variant checkout**: `variantBranch` outranks every subscription, so the suite's own fixture accounts resolve YOUR branch. Re-run from trunk or with `--variant-branch none` before treating it as a regression. |
2904
2937
  | `components sync` on a branch says "No changes detected" but trunk has moved | Re-run it; a trunk sync now invalidates the branch's cached verdict. If it still skips, the branch genuinely matches trunk - check `components branch <name>` for what is actually stored. |
2938
+ | A sync summary reports `skipped: true` with `NO COMPONENTS WERE SYNCED` | The branch head matches the cached sync SHA, so the branch was never re-read — this is NOT an empty plan. It matters when something else changed the overlays at that same SHA: an ordinary re-sync then answers "nothing to do" forever. `--dry-run` always re-reads the branch and shows the real plan; `--force-tombstones` re-reads and applies it. |
2939
+ | A branch sync stores overlays for components you never edited on the branch | **Trunk moved.** Sparseness compares the branch against CURRENT trunk, so editing a component on trunk without merging trunk into the branch turns it into a branch overlay on the next branch sync. The branch did not change. Merge trunk in, push, re-sync — the overlays prune. Check `components promotion` for the phase. |
2905
2940
  | After promoting a `new_*` component to trunk, the branch still shows it as `added` | You have not re-synced the branch since the trunk sync. Merge trunk into the branch and `components sync`: the file adopts the promoted id and, if unchanged, removes its own overlay. If the branch carries BOTH `new_Foo.*` and `<id>_Foo.*`, the id file wins and the `new_` one is reported `skipped: superseded` - delete it. |
2906
2941
  | After promoting a standalone Prompt, the branch still shows `OVERRIDDEN prompt:<id>` with only `purpose` changed | The trunk Prompt row does not match repo metadata. Ensure the Prompt sidecar has `description` and `purpose: CUSTOM`, run a platform build that imports Prompt `purpose`, re-sync trunk, merge back, and re-sync the branch. |
2907
2942
  | A branch preview reports a `removed` component nobody deleted | Check whether that component has a file on **trunk**. A DB row with no trunk file is missing from every branch, so it reads as a removal everywhere (and the trunk sync tries to hard-delete it each run). Repair trunk, not the branch. |