@remits/remits-cli 0.1.110 → 0.1.113

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
@@ -320,6 +320,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
320
320
 
321
321
  const dataMode = requestedDataMode || resolveDataMode(flags, session);
322
322
  const branchName = flags.branch || flags.branchName || currentBranch(cwd);
323
+ const workspace = resolveWorkspace(cwd, flags);
323
324
  const user = session && session.user ? session.user : {};
324
325
  const repoName = accountNameFromAccountInfo(accountInfo);
325
326
  const localAccountInfoAccountId = accountInfo ? Number(accountIdFromAccountInfo(accountInfo)) : null;
@@ -336,6 +337,8 @@ function resolveSessionIdentity(cwd, flags = {}) {
336
337
  userId: user.id || session?.userId || null,
337
338
  userName: user.name || user.username || user.email || null,
338
339
  branchName,
340
+ workspace,
341
+ workspaceSource: workspaceSource(cwd, flags),
339
342
  dataMode,
340
343
  // `test run` resolves its lane from the FLAG ONLY (testRunCommand), never the stored session, so that
341
344
  // an account whose session is parked on prod cannot have a test run silently follow it. Report both
@@ -358,6 +361,7 @@ function printSessionIdentity(identity, options = {}) {
358
361
  console.log('Account ID:', identity.accountId || 'unresolved');
359
362
  console.log('User ID:', 'unresolved');
360
363
  console.log('Branch:', identity.branchName || 'unresolved');
364
+ console.log('Workspace:', identity.workspace || 'none (shared default lane)');
361
365
  console.log('Data mode:', (identity.dataMode || DEFAULT_DATA_MODE) + ' (applies to tool/tools/token)');
362
366
  console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE);
363
367
  console.log('Base URL:', identity.baseUrl);
@@ -382,6 +386,8 @@ function printSessionIdentity(identity, options = {}) {
382
386
  console.log(' User:', identity.userName);
383
387
  }
384
388
  console.log(' Branch:', identity.branchName);
389
+ console.log(' Workspace:', (identity.workspace || 'none (shared default lane)') +
390
+ (identity.workspaceSource ? ' [' + identity.workspaceSource + ']' : ''));
385
391
  console.log(' Data mode:', identity.dataMode, '(applies to tool/tools/token)');
386
392
  // `test run` deliberately ignores the stored session lane and defaults to test, so a session sitting on
387
393
  // prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
@@ -528,10 +534,105 @@ function localStatePaths(cwd) {
528
534
  toolsDir: path.join(base, 'tools'),
529
535
  sessionsDir: path.join(base, 'sessions'),
530
536
  toolResponsesDir: path.join(base, 'tool-responses'),
531
- currentSessionFile: path.join(base, 'current-session.txt')
537
+ currentSessionFile: path.join(base, 'current-session.txt'),
538
+ workspaceFile: path.join(base, 'workspace')
532
539
  };
533
540
  }
534
541
 
542
+ // ---------------------------------------------------------------------------------------------------
543
+ // Staging workspace
544
+ // ---------------------------------------------------------------------------------------------------
545
+ //
546
+ // A workspace is an OPTIONAL second isolation axis for staging, alongside the git branch. It exists so
547
+ // several coding agents can iterate on the SAME branch at once without overwriting each other: staging is
548
+ // keyed by (account, cliUser, branch, workspace), and account and user are fixed for a given repo.
549
+ //
550
+ // It is stored in `.remits-cli/workspace`, which is PER-DIRECTORY and gitignored — so `git worktree add`
551
+ // gives each agent its own file, and `remits-cli workspace use --auto` names the lane after the worktree
552
+ // directory. That makes the ergonomic path a one-time setup per worktree rather than a flag on every
553
+ // command, which is what an agent will actually get right.
554
+ //
555
+ // Precedence: explicit --workspace > REMITS_WORKSPACE env > .remits-cli/workspace > none (shared lane).
556
+
557
+ const WORKSPACE_MAX_LENGTH = 48;
558
+
559
+ // MUST match CliComponentStaging.normalizeWorkspace on the server. Normalizing here too means the name an
560
+ // agent sees in its own output is the lane the server actually used, rather than a raw string the server
561
+ // silently rewrote.
562
+ function normalizeWorkspace(value) {
563
+ if (value === undefined || value === null) return null;
564
+ const trimmed = String(value).trim();
565
+ if (!trimmed) return null;
566
+ let normalized = trimmed
567
+ .toLowerCase()
568
+ .replace(/[^a-z0-9._-]/g, '-')
569
+ .replace(/-+/g, '-')
570
+ .replace(/^[-.]+/, '')
571
+ .replace(/[-.]+$/, '');
572
+ if (normalized.length > WORKSPACE_MAX_LENGTH) {
573
+ normalized = normalized.slice(0, WORKSPACE_MAX_LENGTH).replace(/[-.]+$/, '');
574
+ }
575
+ return normalized || null;
576
+ }
577
+
578
+ function readWorkspaceFile(cwd) {
579
+ try {
580
+ const file = localStatePaths(cwd).workspaceFile;
581
+ if (!fs.existsSync(file)) return null;
582
+ return normalizeWorkspace(fs.readFileSync(file, 'utf8'));
583
+ } catch (_) {
584
+ return null;
585
+ }
586
+ }
587
+
588
+ function writeWorkspaceFile(cwd, workspace) {
589
+ const paths = ensureLocalState(cwd);
590
+ if (!workspace) {
591
+ try { if (fs.existsSync(paths.workspaceFile)) fs.unlinkSync(paths.workspaceFile); } catch (_) { /* best-effort */ }
592
+ return null;
593
+ }
594
+ atomicWriteFile(paths.workspaceFile, workspace + '\n');
595
+ return workspace;
596
+ }
597
+
598
+ // `--no-workspace` explicitly opts back into the shared default lane even when a workspace file exists,
599
+ // so a command can be run against the lane a teammate (or an older session) staged into without deleting
600
+ // local state.
601
+ function resolveWorkspace(cwd, flags = {}) {
602
+ if (flagEnabled(flags['no-workspace']) || flagEnabled(flags.noWorkspace)) {
603
+ return null;
604
+ }
605
+ const explicit = flags.workspace || flags.ws;
606
+ if (explicit !== undefined && explicit !== null && explicit !== true) {
607
+ return normalizeWorkspace(explicit);
608
+ }
609
+ return normalizeWorkspace(process.env.REMITS_WORKSPACE) || readWorkspaceFile(cwd);
610
+ }
611
+
612
+ // Where the resolved workspace came from. Printed alongside it so an agent never has to guess whether the
613
+ // lane it is on was chosen deliberately or inherited from a file it forgot about.
614
+ function workspaceSource(cwd, flags = {}) {
615
+ if (flagEnabled(flags['no-workspace']) || flagEnabled(flags.noWorkspace)) return 'explicit --no-workspace';
616
+ const explicit = flags.workspace || flags.ws;
617
+ if (explicit !== undefined && explicit !== null && explicit !== true) return '--workspace';
618
+ if (normalizeWorkspace(process.env.REMITS_WORKSPACE)) return 'REMITS_WORKSPACE';
619
+ if (readWorkspaceFile(cwd)) return '.remits-cli/workspace';
620
+ return 'none (shared default lane)';
621
+ }
622
+
623
+ // One line, printed by every command that stages, runs, or mints a token. The whole point of the feature
624
+ // is that several lanes can exist at once; a command that did not say which one it used would trade one
625
+ // kind of confusion for another.
626
+ function printStagingLane(branchName, workspace, source) {
627
+ if (workspace) {
628
+ console.log('Staging lane:', branchName + ' [workspace: ' + workspace + ']' +
629
+ (source ? ' (from ' + source + ')' : ''));
630
+ } else {
631
+ console.log('Staging lane:', branchName + ' [shared default lane]' +
632
+ (source ? ' (' + source + ')' : ''));
633
+ }
634
+ }
635
+
535
636
  function ensureLocalState(cwd) {
536
637
  const paths = localStatePaths(cwd);
537
638
  ensureDir(paths.base);
@@ -1147,13 +1248,45 @@ function countAccountRepos(index) {
1147
1248
  return listAccountRepoEntries(index).length;
1148
1249
  }
1149
1250
 
1251
+ /**
1252
+ * Fold discovered checkouts into the index, one entry per account.
1253
+ *
1254
+ * <p><b>Two checkouts can claim one account, and the naive merge picked a silent winner.</b> The index is
1255
+ * keyed by accountId, so a plain last-write-wins loop resolved a fork, a worktree or a dated backup copy
1256
+ * by scan order — which is alphabetical, which is meaningless. Measured on this machine: account 33 had
1257
+ * both `remits-invoice-automation` and `remits-invoice-automation copy-EOD-09302025`, and the index held
1258
+ * the dated copy. Four open tickets would have been worked in a month-old snapshot.</p>
1259
+ *
1260
+ * <p>So: a real checkout always beats a non-checkout (an unpacked export cannot win merely by sorting
1261
+ * later); an explicit `cwd` entry — the operator standing in the directory — always beats a scan; and
1262
+ * when two genuine checkouts remain, the incumbent is KEPT and the loser is recorded in `ambiguousWith`
1263
+ * rather than discarded. Ambiguity a person can see is recoverable; a silent winner is not.</p>
1264
+ */
1150
1265
  function mergeAccountRepoEntries(entries, options = {}) {
1151
1266
  const nextIndex = options.existingIndex ? { ...options.existingIndex } : {};
1267
+ const rank = (entry) => (isAccountCheckout(entry.directory) ? 2 : 0) + (entry.source === 'cwd' ? 1 : 0);
1268
+
1152
1269
  for (const entry of entries) {
1153
1270
  if (!entry || !entry.accountId || !entry.directory) {
1154
1271
  continue;
1155
1272
  }
1156
- nextIndex[String(entry.accountId)] = entry;
1273
+ const key = String(entry.accountId);
1274
+ const current = nextIndex[key];
1275
+ if (!current || !current.directory || current.directory === entry.directory) {
1276
+ nextIndex[key] = { ...entry, ambiguousWith: (current && current.ambiguousWith) || [] };
1277
+ continue;
1278
+ }
1279
+
1280
+ const winner = rank(entry) > rank(current) ? entry : current;
1281
+ const loser = winner === entry ? current : entry;
1282
+ const seen = new Set([...((current.ambiguousWith) || []), ...((entry.ambiguousWith) || [])]);
1283
+ // Only a genuine rival is worth reporting. A non-checkout losing to a checkout is the system
1284
+ // working, not a decision the operator needs to review.
1285
+ if (isAccountCheckout(loser.directory) && isAccountCheckout(winner.directory)) {
1286
+ seen.add(loser.directory);
1287
+ }
1288
+ seen.delete(winner.directory);
1289
+ nextIndex[key] = { ...winner, ambiguousWith: [...seen] };
1157
1290
  }
1158
1291
  writeAccountRepoIndex(nextIndex);
1159
1292
  return nextIndex;
@@ -1615,6 +1748,25 @@ function parsePositiveInt(value, fallback) {
1615
1748
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
1616
1749
  }
1617
1750
 
1751
+ /**
1752
+ * How long to give a full stage, from how much it is actually uploading.
1753
+ *
1754
+ * A fixed 60s was fine for a small repo and wrong for a real one: the request completes server-side and
1755
+ * the client abandons it, which is indistinguishable at the terminal from a stage that never landed.
1756
+ * Roughly 60s of headroom per megabyte on top of a 60s floor, capped so a pathological payload cannot
1757
+ * hang a session forever. `--stage-timeout-ms` overrides it.
1758
+ */
1759
+ function stageTimeoutMs(components, flags = {}) {
1760
+ const override = parsePositiveInt(flags['stage-timeout-ms'] || flags.stageTimeoutMs, 0);
1761
+ if (override) return override;
1762
+ let bytes = 0;
1763
+ for (const component of components || []) {
1764
+ try { bytes += Buffer.byteLength(JSON.stringify(component)); } catch (_) { /* unmeasurable is fine */ }
1765
+ }
1766
+ const megabytes = bytes / (1024 * 1024);
1767
+ return Math.min(600000, Math.max(60000, Math.round(60000 + (megabytes * 60000))));
1768
+ }
1769
+
1618
1770
  function buildAxios(baseUrl, token, timeoutMs = 60000) {
1619
1771
  const headers = token ? { Authorization: 'Bearer ' + token } : {};
1620
1772
  return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
@@ -2059,24 +2211,54 @@ async function pushComponentsCommand(flags) {
2059
2211
  const { session, accountId } = sessionContext;
2060
2212
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2061
2213
  const branchName = flags.branch || currentBranch(cwd);
2214
+ const workspace = resolveWorkspace(cwd, flags);
2062
2215
  const dataMode = resolveDataMode(flags, session);
2063
2216
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
2064
2217
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2065
- const components = collectComponents(cwd);
2066
2218
  const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2067
2219
 
2220
+ // --changed-only stages just the components this working tree edited, instead of re-uploading all of
2221
+ // them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
2222
+ // deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
2223
+ // components this partial stage did not mention.
2224
+ const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2225
+ let components = collectComponents(cwd);
2226
+ if (changedOnly) {
2227
+ if (changedFromWorkingTree === null) {
2228
+ throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
2229
+ }
2230
+ const wanted = new Set(changedFromWorkingTree.map((entry) =>
2231
+ entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2232
+ components = components.filter((component) => wanted.has(
2233
+ component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2234
+ if (!components.length) {
2235
+ console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
2236
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2237
+ return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
2238
+ }
2239
+ }
2240
+
2068
2241
  if (requestedMode === 'push') {
2069
2242
  console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
2070
2243
  }
2071
2244
 
2072
- const api = buildAxios(baseUrl, session.token);
2245
+ // A full stage uploads the WHOLE repo, and a large one is megabytes: Merchant Statements is 9.6 MB
2246
+ // across 106 components. Against a remote platform that routinely runs past 60s — and the failure was
2247
+ // the worst possible shape, because the server had already stored everything: the CLI reported
2248
+ // "timeout ... ECONNABORTED" over a stage that had SUCCEEDED, so the caller would either re-upload it
2249
+ // or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
2250
+ // the old default, and overridable.
2251
+ const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
2073
2252
  const response = await loggedPost(api, cwd, '/cli/components', {
2074
2253
  token: session.token,
2075
2254
  accountId,
2076
2255
  branchName,
2256
+ workspace,
2077
2257
  dataMode,
2078
2258
  mode,
2079
- replace: true,
2259
+ // A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
2260
+ // every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
2261
+ replace: !changedOnly,
2080
2262
  components
2081
2263
  }).then((r) => r.data);
2082
2264
  response.changedFromWorkingTree = changedFromWorkingTree || [];
@@ -2089,8 +2271,9 @@ async function pushComponentsCommand(flags) {
2089
2271
 
2090
2272
  printSessionResolutionWarning(sessionContext);
2091
2273
  printResolvedBaseUrl(baseUrl);
2274
+ printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
2092
2275
  console.log('Data mode:', response.dataMode || dataMode);
2093
- console.log('Mode:', response.mode || mode);
2276
+ console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
2094
2277
  printStageSummary(response, flags);
2095
2278
  }
2096
2279
 
@@ -2166,8 +2349,26 @@ function printStageSummary(response, flags) {
2166
2349
 
2167
2350
  function printStatusSummary(response, flags) {
2168
2351
  printBranchContext(response);
2352
+ printStagingLane(response.branchName, response.workspace, null);
2169
2353
  console.log('Staged count:', response.stagedCount || 0);
2170
2354
  printComponentTypeCounts(response.entries || []);
2355
+
2356
+ // Every lane staged on this branch, not just this one. An agent that believes it is working alone can
2357
+ // see here that it is not — which was impossible before workspaces existed, and is a large part of why
2358
+ // concurrent agents overwrote each other without anyone being able to observe it.
2359
+ const lanes = Array.isArray(response.workspaces) ? response.workspaces : [];
2360
+ if (lanes.length > 1 || (lanes.length === 1 && lanes[0].workspace !== (response.workspace || null))) {
2361
+ console.log('');
2362
+ console.log('Other staging lanes on branch "' + response.branchName + '" for this account/user:');
2363
+ lanes.forEach((lane) => {
2364
+ const isCurrent = (lane.workspace || null) === (response.workspace || null);
2365
+ const label = lane.workspace ? 'workspace ' + lane.workspace : 'shared default lane';
2366
+ const ttl = lane.ttlSeconds != null ? ' expires in ~' + Math.round(lane.ttlSeconds / 60) + 'm' : '';
2367
+ console.log(' ' + (isCurrent ? '* ' : ' ') + label + ' — ' + (lane.keyCount || 0) + ' staged key(s)' + ttl);
2368
+ });
2369
+ console.log(' (* = this command\'s lane)');
2370
+ }
2371
+
2171
2372
  printComponentCommandResponse('Components staging', response, flags);
2172
2373
  }
2173
2374
 
@@ -2210,6 +2411,64 @@ function printClearSummary(response, flags) {
2210
2411
  printComponentCommandResponse('Components clear', response, flags);
2211
2412
  }
2212
2413
 
2414
+ /**
2415
+ * `remits-cli workspace [show|use <name>|clear]`
2416
+ *
2417
+ * The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
2418
+ * checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
2419
+ * an agent its own isolated staging lane on the SAME branch.
2420
+ */
2421
+ async function workspaceCommand(flags) {
2422
+ const cwd = process.cwd();
2423
+ ensureLocalState(cwd);
2424
+ const sub = String((flags._ && flags._[1]) || 'show').toLowerCase();
2425
+
2426
+ if (sub === 'clear' || sub === 'unset' || sub === 'none') {
2427
+ writeWorkspaceFile(cwd, null);
2428
+ console.log('Workspace cleared. This checkout now stages into the shared default lane for its branch.');
2429
+ return;
2430
+ }
2431
+
2432
+ if (sub === 'use' || sub === 'set') {
2433
+ const auto = flagEnabled(flags.auto);
2434
+ const requested = auto ? path.basename(cwd) : (flags._ && flags._[2]) || flags.name || flags.workspace;
2435
+ if (!requested) {
2436
+ throw new Error('Usage: remits-cli workspace use <name> (or --auto to name it after this directory)');
2437
+ }
2438
+ const normalized = normalizeWorkspace(requested);
2439
+ if (!normalized) {
2440
+ throw new Error('Workspace name "' + requested + '" normalizes to nothing. Use letters, digits, dot, dash or underscore.');
2441
+ }
2442
+ writeWorkspaceFile(cwd, normalized);
2443
+ console.log('Workspace set to:', normalized);
2444
+ if (normalized !== String(requested).trim()) {
2445
+ // Say so rather than silently accepting a name that is not the lane. An agent that thinks it is on
2446
+ // "Agent A" and is actually on "agent-a" cannot reconcile its own output with a status listing.
2447
+ console.log(' (normalized from "' + String(requested).trim() + '")');
2448
+ }
2449
+ console.log('Stored in:', localStatePaths(cwd).workspaceFile, '(gitignored, per-checkout)');
2450
+ console.log('');
2451
+ console.log('Every stage / test run / token from this directory now uses this lane.');
2452
+ console.log('It does NOT change which branch a commit targets, or which committed variants run.');
2453
+ return;
2454
+ }
2455
+
2456
+ if (sub !== 'show' && sub !== 'status') {
2457
+ throw new Error('Unknown workspace subcommand "' + sub + '". Expected show, use, or clear.');
2458
+ }
2459
+
2460
+ const branchName = flags.branch || currentBranch(cwd);
2461
+ const workspace = resolveWorkspace(cwd, flags);
2462
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2463
+ console.log('');
2464
+ console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
2465
+ console.log('');
2466
+ console.log(' remits-cli workspace use <name> set this checkout\'s lane');
2467
+ console.log(' remits-cli workspace use --auto name it after this directory (' + normalizeWorkspace(path.basename(cwd)) + ')');
2468
+ console.log(' remits-cli workspace clear go back to the shared default lane');
2469
+ console.log(' remits-cli components status see every lane staged on this branch');
2470
+ }
2471
+
2213
2472
  async function statusComponentsCommand(flags) {
2214
2473
  const cwd = process.cwd();
2215
2474
  ensureLocalState(cwd);
@@ -2217,6 +2476,7 @@ async function statusComponentsCommand(flags) {
2217
2476
  const { session, accountId } = sessionContext;
2218
2477
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2219
2478
  const branchName = flags.branch || currentBranch(cwd);
2479
+ const workspace = resolveWorkspace(cwd, flags);
2220
2480
  const dataMode = resolveDataMode(flags, session);
2221
2481
  const api = buildAxios(baseUrl, session.token);
2222
2482
 
@@ -2224,6 +2484,7 @@ async function statusComponentsCommand(flags) {
2224
2484
  token: session.token,
2225
2485
  accountId,
2226
2486
  branchName,
2487
+ workspace,
2227
2488
  dataMode,
2228
2489
  mode: 'status',
2229
2490
  componentType: flags['component-type'] || flags.type,
@@ -2272,6 +2533,9 @@ async function clearComponentsCommand(flags) {
2272
2533
  const response = await loggedPost(api, cwd, '/cli/components', {
2273
2534
  token: session.token,
2274
2535
  accountId,
2536
+ // A clear NEVER reaches beyond this lane, so `--all` means "all of MY staged entries", not
2537
+ // "everything staged on this branch by anyone".
2538
+ workspace: resolveWorkspace(cwd, flags),
2275
2539
  branchName,
2276
2540
  dataMode,
2277
2541
  mode: 'clear',
@@ -2291,6 +2555,7 @@ async function clearComponentsCommand(flags) {
2291
2555
 
2292
2556
  printSessionResolutionWarning(sessionContext);
2293
2557
  printResolvedBaseUrl(baseUrl);
2558
+ printStagingLane(response.branchName || branchName, response.workspace, workspaceSource(cwd, flags));
2294
2559
  console.log('Data mode:', response.dataMode || dataMode);
2295
2560
  console.log('Mode:', response.mode || 'clear');
2296
2561
  printClearSummary(response, flags);
@@ -2304,6 +2569,7 @@ async function syncComponentsCommand(flags) {
2304
2569
  const { session, accountId } = sessionContext;
2305
2570
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2306
2571
  const branchName = flags.branch || currentBranch(cwd);
2572
+ const workspace = resolveWorkspace(cwd, flags);
2307
2573
  const dataMode = resolveDataMode(flags, session);
2308
2574
  const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
2309
2575
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
@@ -2325,6 +2591,11 @@ async function syncComponentsCommand(flags) {
2325
2591
  token: session.token,
2326
2592
  accountId,
2327
2593
  branchName,
2594
+ // A successful sync clears the staging scope so stale staged aliases cannot shadow the freshly synced
2595
+ // DB rows. Sending the workspace keeps that clear scoped to THIS lane: without it, one agent's sync
2596
+ // would wipe every sibling agent's staged work on the same branch, which is the exact failure the
2597
+ // workspace lane exists to remove.
2598
+ workspace,
2328
2599
  dataMode,
2329
2600
  forceTombstones,
2330
2601
  dryRun,
@@ -2338,6 +2609,7 @@ async function syncComponentsCommand(flags) {
2338
2609
  token: session.token,
2339
2610
  accountId,
2340
2611
  branchName,
2612
+ workspace,
2341
2613
  dataMode,
2342
2614
  mode: 'status'
2343
2615
  }).then((r) => r.data);
@@ -3230,6 +3502,7 @@ async function testCommand(flags) {
3230
3502
  const { session, accountId } = sessionContext;
3231
3503
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3232
3504
  const branchName = flags.branch || currentBranch(cwd);
3505
+ const workspace = resolveWorkspace(cwd, flags);
3233
3506
  // Test runs must default to test dataMode even when the most-recent authenticated
3234
3507
  // session for the account is prod. Production test runs therefore require an explicit
3235
3508
  // --data-mode prod on the command line.
@@ -3285,6 +3558,7 @@ async function testCommand(flags) {
3285
3558
  asAccountId,
3286
3559
  variantBranch,
3287
3560
  branchName,
3561
+ workspace,
3288
3562
  dataMode,
3289
3563
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
3290
3564
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
@@ -3297,6 +3571,7 @@ async function testCommand(flags) {
3297
3571
 
3298
3572
  printSessionResolutionWarning(sessionContext);
3299
3573
  printResolvedBaseUrl(baseUrl);
3574
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
3300
3575
  console.log('Test run started:', start.taskId);
3301
3576
  runtimeState.currentTestTaskId = start.taskId;
3302
3577
 
@@ -3354,6 +3629,7 @@ async function tokenCommand(flags) {
3354
3629
  const { session, accountId } = sessionContext;
3355
3630
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3356
3631
  const branchName = flags.branch || currentBranch(cwd);
3632
+ const workspace = resolveWorkspace(cwd, flags);
3357
3633
  const dataMode = resolveDataMode(flags, session);
3358
3634
 
3359
3635
  const api = buildAxios(baseUrl, session.token);
@@ -3371,6 +3647,7 @@ async function tokenCommand(flags) {
3371
3647
  asAccountId: flags['as-account'] || flags['as-account-id'],
3372
3648
  variantBranch,
3373
3649
  branchName,
3650
+ workspace,
3374
3651
  dataMode,
3375
3652
  path: requestedPath
3376
3653
  }).then((r) => r.data);
@@ -3396,6 +3673,8 @@ async function tokenCommand(flags) {
3396
3673
  accountId: data.accountId,
3397
3674
  branchName: data.branchName,
3398
3675
  variantBranch: data.variantBranch,
3676
+ workspace: data.workspace !== undefined ? data.workspace : workspace,
3677
+ stagingLane: data.stagingLane,
3399
3678
  dataMode: data.dataMode || dataMode,
3400
3679
  shortBasePath: data.shortBasePath,
3401
3680
  embeddableUrl
@@ -3461,6 +3740,7 @@ async function toolsCommand(flags) {
3461
3740
  const { session, accountId } = sessionContext;
3462
3741
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3463
3742
  const branchName = flags.branch || currentBranch(cwd);
3743
+ const workspace = resolveWorkspace(cwd, flags);
3464
3744
  const dataMode = resolveDataMode(flags, session);
3465
3745
  const variantBranch = flags['variant-branch'];
3466
3746
  const api = buildAxios(baseUrl, session.token);
@@ -3470,6 +3750,7 @@ async function toolsCommand(flags) {
3470
3750
  accountId,
3471
3751
  variantBranch,
3472
3752
  branchName,
3753
+ workspace,
3473
3754
  dataMode
3474
3755
  }).then((r) => r.data);
3475
3756
 
@@ -3507,6 +3788,51 @@ function parseToolInput(flags) {
3507
3788
  return {};
3508
3789
  }
3509
3790
 
3791
+ const TOOL_SCOPES = ['self', 'children', 'hierarchy', 'parents'];
3792
+
3793
+ // Hierarchy-scoped discovery flags, folded into the tool's own input.
3794
+ //
3795
+ // They are CONVENIENCE only: every one of them can be written inside --input, and a value already there
3796
+ // wins, because the explicit JSON is what the caller typed for this specific tool. The flags exist so the
3797
+ // overwhelmingly common shape — "search my repo account's children for this id" — does not require
3798
+ // hand-writing JSON.
3799
+ function applyScopeFlags(input, flags) {
3800
+ const merged = input && typeof input === 'object' ? input : {};
3801
+
3802
+ const scope = flags && (flags.scope || flags['tool-scope']);
3803
+ if (scope && merged.scope === undefined) {
3804
+ const normalized = String(scope).trim().toLowerCase();
3805
+ if (!TOOL_SCOPES.includes(normalized)) {
3806
+ throw new Error('Invalid --scope "' + scope + '". Valid values: ' + TOOL_SCOPES.join(', '));
3807
+ }
3808
+ merged.scope = normalized;
3809
+ }
3810
+
3811
+ const targetAccountId = flags && (flags['target-account-id'] || flags.targetAccountId);
3812
+ if (targetAccountId && merged.targetAccountId === undefined) {
3813
+ merged.targetAccountId = String(targetAccountId).trim();
3814
+ }
3815
+
3816
+ const anchorAccountId = flags && (flags['anchor-account-id'] || flags.anchorAccountId);
3817
+ if (anchorAccountId && merged.anchorAccountId === undefined) {
3818
+ merged.anchorAccountId = String(anchorAccountId).trim();
3819
+ }
3820
+
3821
+ const accountIds = flags && (flags['account-ids'] || flags.accountIds);
3822
+ if (accountIds && merged.accountIds === undefined) {
3823
+ const parsed = String(accountIds)
3824
+ .split(',')
3825
+ .map((part) => Number(String(part).trim()))
3826
+ .filter((value) => Number.isFinite(value) && value > 0);
3827
+ if (!parsed.length) {
3828
+ throw new Error('--account-ids expects a comma-separated list of numeric account ids');
3829
+ }
3830
+ merged.accountIds = parsed;
3831
+ }
3832
+
3833
+ return merged;
3834
+ }
3835
+
3510
3836
  async function toolCommand(flags) {
3511
3837
  const cwd = process.cwd();
3512
3838
  ensureLocalState(cwd);
@@ -3525,7 +3851,7 @@ async function toolCommand(flags) {
3525
3851
  const explicitFlagAccountId = Number(flags && flags['account-id']);
3526
3852
  const accountIdExplicit = Number.isFinite(explicitFlagAccountId) && explicitFlagAccountId > 0;
3527
3853
 
3528
- const input = parseToolInput(flags);
3854
+ const input = applyScopeFlags(parseToolInput(flags), flags);
3529
3855
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3530
3856
  const branchName = flags.branch || currentBranch(cwd);
3531
3857
  const dataMode = resolveDataMode(flags, session);
@@ -3591,7 +3917,12 @@ async function toolCommand(flags) {
3591
3917
  token: session.token,
3592
3918
  accountId,
3593
3919
  accountIdExplicit,
3920
+ // The repo/session account, sent as a FIRST-CLASS value rather than left to be inferred from
3921
+ // `accountId`: a read tool may reinterpret its own `input.accountId`, and the scope root — what bounds
3922
+ // what the call may discover — has to survive that. The server validates it; it is never authorization.
3923
+ scopeRootAccountId: accountId,
3594
3924
  branchName,
3925
+ workspace: resolveWorkspace(cwd, flags),
3595
3926
  dataMode,
3596
3927
  variantBranch,
3597
3928
  name: String(toolName),
@@ -4415,6 +4746,7 @@ function exportIssueBundle(snapshot) {
4415
4746
  agents: {
4416
4747
  localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
4417
4748
  registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
4749
+ workerCount: snapshot.agents && snapshot.agents.workers ? snapshot.agents.workers.length : 0,
4418
4750
  errors: (snapshot.agents && snapshot.agents.errors) || []
4419
4751
  },
4420
4752
  repos: (snapshot.repos || []).map((repo) => ({
@@ -4528,6 +4860,31 @@ function buildRepoSnapshot(entry) {
4528
4860
  };
4529
4861
  }
4530
4862
 
4863
+ /**
4864
+ * The first argument that was actually SUPPLIED, empty string included.
4865
+ *
4866
+ * `a || b` cannot express this: an empty string is a real value on any field that can be cleared, and it
4867
+ * is falsy, so `||` silently turns "clear this" into "say nothing about this".
4868
+ */
4869
+ function firstDefined(...values) {
4870
+ for (const value of values) {
4871
+ if (value !== undefined && value !== null) return value;
4872
+ }
4873
+ return undefined;
4874
+ }
4875
+
4876
+ /**
4877
+ * A planning flag's value, where a valueless flag means CLEAR.
4878
+ *
4879
+ * `parseArgs` reports both `--planned-in` and `--planned-in ""` as `true`, because an empty next argument
4880
+ * is indistinguishable from an absent one. A planning slot is a field an org must be able to blank, so the
4881
+ * valueless form is given that meaning rather than being sent to the server as the literal string "true".
4882
+ */
4883
+ function planningFlagValue(value) {
4884
+ if (value === undefined || value === null) return undefined;
4885
+ return value === true ? '' : value;
4886
+ }
4887
+
4531
4888
  function compactSupportTicketRow(row) {
4532
4889
  if (!row || typeof row !== 'object') {
4533
4890
  return null;
@@ -4545,9 +4902,18 @@ function compactSupportTicketRow(row) {
4545
4902
  routedAgentId: row.routedAgentId || null,
4546
4903
  routedAgentLabel: row.routedAgentLabel || null,
4547
4904
  routedAt: row.routedAt || null,
4905
+ claimedAgentId: row.claimedAgentId || null,
4906
+ awaitingResponseSince: row.awaitingResponseSince || null,
4907
+ awaitingResponseQuestion: row.awaitingResponseQuestion || null,
4548
4908
  implementationAccountId: row.implementationAccountId || null,
4549
4909
  implementationAccountName: row.implementationAccountName || null,
4550
4910
  affectedComponent: row.affectedComponent || null,
4911
+ workstream: row.workstream || null,
4912
+ plannedIn: row.plannedIn || null,
4913
+ boardStage: row.boardStage || null,
4914
+ rank: row.rank === undefined ? null : row.rank,
4915
+ size: row.size || null,
4916
+ blockedBy: row.blockedBy || null,
4551
4917
  updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
4552
4918
  activityAt: row.activityAt || row.lastActivityAt || null,
4553
4919
  activityLabel: row.activityLabel || null
@@ -4565,8 +4931,13 @@ function compactSupportTicketRow(row) {
4565
4931
  async function fetchSupportTicketQueueForSession(session) {
4566
4932
  const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4567
4933
  const api = buildAxios(baseUrl, session.token, 15000);
4934
+ // The lane is PINNED, not left to the server's default. Without it the queue this row claims to
4935
+ // describe is whichever lane the platform happened to choose, so a row labelled with a session's
4936
+ // lane could be showing the other one.
4937
+ const dataMode = normalizeDataMode(session.dataMode || 'prod');
4568
4938
  const result = await api.post('/cli/tickets', {
4569
4939
  accountId: Number(session.accountId),
4940
+ dataMode,
4570
4941
  includeChildren: true,
4571
4942
  sortBy: 'triage',
4572
4943
  limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
@@ -4580,7 +4951,8 @@ async function fetchSupportTicketQueueForSession(session) {
4580
4951
  baseUrl,
4581
4952
  accountId: Number(session.accountId),
4582
4953
  userId: session.user && session.user.id ? session.user.id : null,
4583
- dataMode: (result && result.dataMode) || session.dataMode || 'prod',
4954
+ dataMode: (result && result.dataMode) || dataMode,
4955
+ compartment: baseUrl + ' · ' + ((result && result.dataMode) || dataMode) + ' lane',
4584
4956
  totalCount: (result && result.totalCount) || tickets.length,
4585
4957
  count: (result && result.count) || tickets.length,
4586
4958
  counts: (result && result.counts) || {},
@@ -4604,27 +4976,46 @@ async function fetchSupportTicketQueueForSession(session) {
4604
4976
  * Goes through the same platform endpoint an operator Embeddable would use, so "route this ticket"
4605
4977
  * has ONE implementation and one access check whichever surface asked for it.
4606
4978
  */
4607
- async function routeTicketFromDashboard({ ticketId, accountId, agentId }) {
4979
+ /**
4980
+ * Route a ticket from the dashboard, on the platform and lane that ticket actually lives on.
4981
+ *
4982
+ * <p>Selecting the session by {@code accountId} alone — and falling back to any authenticated session
4983
+ * at all — was a cross-platform write triggered from a button. Account 1 exists on production and on
4984
+ * a localhost instance both, so routing a localhost test fixture could reach production, or the
4985
+ * reverse. The ticket carries the compartment it came from; that is what decides where the write goes,
4986
+ * and if no session matches it we refuse rather than write somewhere plausible.</p>
4987
+ */
4988
+ async function routeTicketFromDashboard({ ticketId, accountId, agentId, baseUrl, dataMode }) {
4608
4989
  const numericAccountId = Number(accountId);
4609
4990
  if (!ticketId || !Number.isFinite(numericAccountId)) {
4610
4991
  throw new Error('Routing needs a ticketId and an accountId.');
4611
4992
  }
4612
- const session = listSessionsFromStore(readAllSessions())
4613
- .find((entry) => entry && entry.token && Number(entry.accountId) === numericAccountId) ||
4614
- listSessionsFromStore(readAllSessions()).find((entry) => entry && entry.token);
4993
+ const wantedBase = baseUrl ? normalizeBaseUrl(baseUrl) : null;
4994
+ const wantedMode = dataMode ? normalizeDataMode(dataMode) : null;
4995
+ const candidates = listSessionsFromStore(readAllSessions()).filter((entry) => {
4996
+ if (!entry || !entry.token) return false;
4997
+ if (wantedBase && normalizeBaseUrl(entry.baseUrl || DEFAULT_BASE_URL) !== wantedBase) return false;
4998
+ if (wantedMode && normalizeDataMode(entry.dataMode || 'prod') !== wantedMode) return false;
4999
+ return true;
5000
+ });
5001
+ const session = candidates.find((entry) => Number(entry.accountId) === numericAccountId) || candidates[0];
4615
5002
  if (!session) {
4616
- throw new Error('No authenticated session is available. Run: remits-cli auth');
5003
+ throw new Error('No authenticated session for ' + (wantedBase || 'that platform') +
5004
+ (wantedMode ? ' in the ' + wantedMode + ' lane' : '') + '. Run: remits-cli auth --base-url ' +
5005
+ (wantedBase || DEFAULT_BASE_URL));
4617
5006
  }
4618
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4619
- const result = await buildAxios(baseUrl, session.token, 15000)
5007
+ const targetBaseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
5008
+ const result = await buildAxios(targetBaseUrl, session.token, 15000)
4620
5009
  .post('/cli/ticketRoute', {
4621
5010
  ticketId: String(ticketId),
4622
5011
  accountId: numericAccountId,
4623
5012
  agentId: agentId || null,
4624
- dataMode: session.dataMode
5013
+ dataMode: wantedMode || session.dataMode
4625
5014
  }).then((r) => r.data);
4626
5015
  appendGlobalActivityLog('ticket', 'routed', {
4627
- ticketId, accountId: numericAccountId, agentId: agentId || null, success: result && result.success
5016
+ ticketId, accountId: numericAccountId, agentId: agentId || null,
5017
+ baseUrl: targetBaseUrl, dataMode: wantedMode || session.dataMode,
5018
+ success: result && result.success
4628
5019
  });
4629
5020
  if (result && result.success === false) {
4630
5021
  throw new Error(result.message || 'Routing was refused.');
@@ -4657,7 +5048,12 @@ async function collectAgentSnapshot() {
4657
5048
  .post('/cli/agents', { accountId: Number(session.accountId), dataMode: session.dataMode })
4658
5049
  .then((r) => r.data);
4659
5050
  for (const agent of (result && result.agents) || []) {
4660
- if (agent && agent.agentId) byAgentId[agent.agentId] = agent;
5051
+ // Stamp the platform and lane it was found on. Agent ids are UUIDs so a collision is unlikely,
5052
+ // but a merged list with no provenance cannot tell a localhost fixture agent from a production
5053
+ // one — the same blending that made the ticket panel report 583.
5054
+ if (agent && agent.agentId) {
5055
+ byAgentId[baseUrl + '|' + agent.agentId] = { ...agent, baseUrl, lane: agent.dataMode || session.dataMode };
5056
+ }
4661
5057
  }
4662
5058
  } catch (err) {
4663
5059
  errors.push({ baseUrl, accountId: Number(session.accountId), message: describeError(err) });
@@ -4670,12 +5066,110 @@ async function collectAgentSnapshot() {
4670
5066
  registered: Object.values(byAgentId)
4671
5067
  .sort((a, b) => Number(b.lastSeenAtMs || 0) - Number(a.lastSeenAtMs || 0)),
4672
5068
  local: listLocalAgents(),
5069
+ // Workers are a purely LOCAL fact — the platform knows a ticket is claimed, not that a process on
5070
+ // this laptop is running it — so they are read from disk here rather than from any endpoint.
5071
+ workers: listLocalAgents().reduce((all, entry) => all.concat(
5072
+ listWorkers(entry.agentId).map((worker) => ({
5073
+ ...worker,
5074
+ agentId: entry.agentId,
5075
+ label: entry.label || entry.agentId,
5076
+ alive: !!(worker.pid && processIsAlive(worker.pid))
5077
+ }))), []),
4673
5078
  errors
4674
5079
  };
4675
5080
  agentSnapshotCache = { fetchedAtMs: nowMs, value };
4676
5081
  return value;
4677
5082
  }
4678
5083
 
5084
+ /**
5085
+ * Split the fetched queues into the compartments a number can honestly be reported for.
5086
+ *
5087
+ * <p>A compartment is one <b>platform and one data lane</b>. Blending them was the headline defect:
5088
+ * the panel summed a production platform's queue with a localhost test-lane queue full of suite
5089
+ * fixtures and presented the result as "OPEN". Prod showed 24; the panel showed 583.</p>
5090
+ *
5091
+ * <p>Three narrower defects rode along with it, and each would still be wrong after separating the
5092
+ * regions:</p>
5093
+ * <ul>
5094
+ * <li><b>The sum double-counted.</b> Two sessions whose account hierarchies overlap both return the
5095
+ * same ticket, and the totals were summed per queue while only the DISPLAYED list was deduped.
5096
+ * Overlap is detectable: {@code /cli/tickets} reports the accounts it actually scanned, so a
5097
+ * queue whose scanned set is contained in another's is dropped before anything is added up.</li>
5098
+ * <li><b>Ticket ids were deduped globally.</b> An id is unique within a platform, not across them —
5099
+ * so a localhost fixture with the same id as a real production ticket silently hid it. Keys are
5100
+ * per compartment now.</li>
5101
+ * <li><b>"Waiting" was total minus routed</b>, which is <i>unrouted</i>, not waiting. With a real
5102
+ * awaiting-a-reply concept on the record, that label was pointing at the wrong thing entirely.</li>
5103
+ * </ul>
5104
+ */
5105
+ function buildTicketCompartments(queues) {
5106
+ const byCompartment = {};
5107
+ for (const queue of queues || []) {
5108
+ const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
5109
+ if (!byCompartment[key]) {
5110
+ byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
5111
+ }
5112
+ byCompartment[key].queues.push(queue);
5113
+ }
5114
+
5115
+ return Object.values(byCompartment).map((group) => {
5116
+ // Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
5117
+ // in that one, so keeping it would count them twice. Equal sets keep the first seen.
5118
+ const scanned = group.queues.map((queue) => new Set(
5119
+ (queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
5120
+ const independent = group.queues.filter((queue, i) => {
5121
+ if (!scanned[i].size) {
5122
+ return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
5123
+ }
5124
+ return !group.queues.some((other, j) => {
5125
+ if (i === j || !scanned[j].size) return false;
5126
+ const contained = [...scanned[i]].every((id) => scanned[j].has(id));
5127
+ if (!contained) return false;
5128
+ // Identical scopes: keep the earlier one, drop the later, or both would drop each other.
5129
+ const identical = scanned[i].size === scanned[j].size;
5130
+ return identical ? j < i : true;
5131
+ });
5132
+ });
5133
+
5134
+ const ticketsById = {};
5135
+ for (const queue of independent) {
5136
+ for (const ticket of queue.tickets || []) {
5137
+ const id = String(ticket.ticketId || '');
5138
+ if (!id || ticketsById[id]) continue;
5139
+ ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
5140
+ dataMode: queue.dataMode, compartment: group.compartment };
5141
+ }
5142
+ }
5143
+ const tickets = Object.values(ticketsById);
5144
+
5145
+ const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
5146
+ const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
5147
+ const assigned = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).assigned || 0), 0);
5148
+
5149
+ return {
5150
+ compartment: group.compartment,
5151
+ baseUrl: group.baseUrl,
5152
+ dataMode: group.dataMode,
5153
+ queueCount: independent.length,
5154
+ overlappingQueuesDropped: group.queues.length - independent.length,
5155
+ // Counted from queues proven to scan disjoint account scopes, so the same ticket is never added
5156
+ // twice. `scopeUnknown` says so out loud when a queue could not report what it scanned — a count
5157
+ // that MIGHT overlap must never read as one that cannot.
5158
+ scopeUnknown: independent.some((queue) => !(queue.searchedAccounts || []).length),
5159
+ open,
5160
+ routed,
5161
+ unrouted: Math.max(0, open - routed),
5162
+ assigned,
5163
+ // Only ever counted over the page actually retrieved, and labelled as such in the UI.
5164
+ awaitingReplyOnPage: tickets.filter((t) => t.awaitingResponseSince).length,
5165
+ accounts: independent.map((queue) => ({
5166
+ accountId: queue.accountId, open: Number(queue.totalCount || 0), scanned: (queue.searchedAccounts || []).length
5167
+ })),
5168
+ tickets: tickets.slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100))
5169
+ };
5170
+ }).sort((a, b) => b.open - a.open);
5171
+ }
5172
+
4679
5173
  async function collectSupportTicketSnapshot() {
4680
5174
  const nowMs = Date.now();
4681
5175
  if (supportTicketSnapshotCache.value &&
@@ -4711,34 +5205,11 @@ async function collectSupportTicketSnapshot() {
4711
5205
  }
4712
5206
  }));
4713
5207
 
4714
- const ticketsById = {};
4715
- for (const queue of queues) {
4716
- for (const ticket of queue.tickets || []) {
4717
- const key = String(ticket.ticketId || '');
4718
- if (!key || ticketsById[key]) {
4719
- continue;
4720
- }
4721
- ticketsById[key] = {
4722
- ...ticket,
4723
- queueAccountId: queue.accountId,
4724
- baseUrl: queue.baseUrl
4725
- };
4726
- }
4727
- }
4728
-
4729
5208
  const value = {
4730
5209
  fetchedAt: new Date(nowMs).toISOString(),
4731
5210
  refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4732
5211
  queueCount: queues.length,
4733
- totalCount: queues.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0),
4734
- counts: queues.reduce((totals, queue) => {
4735
- const counts = queue.counts || {};
4736
- totals.routed = Number(totals.routed || 0) + Number(counts.routed || 0);
4737
- totals.unrouted = Number(totals.unrouted || 0) + Number(counts.unrouted || 0);
4738
- totals.assigned = Number(totals.assigned || 0) + Number(counts.assigned || 0);
4739
- return totals;
4740
- }, {}),
4741
- tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
5212
+ compartments: buildTicketCompartments(queues),
4742
5213
  queues,
4743
5214
  errors
4744
5215
  };
@@ -4793,7 +5264,8 @@ function renderDashboardHtml(snapshot) {
4793
5264
  ':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;}' +
4794
5265
  '*{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));}' +
4795
5266
  '.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;}' +
4796
- '.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;}}' +
5267
+ '.wlog{max-height:460px;overflow:auto;background:#1f1c16;color:#f4ead7;border-radius:12px;border:1px solid #3f382d;padding:12px;font-size:12px;line-height:1.5;} .wlog .ev{padding:6px 8px;border-radius:8px;margin-bottom:6px;white-space:pre-wrap;word-break:break-word;} .wlog .cmd{background:#2b271f;border-left:3px solid #8fb98f;} .wlog .cmd.bad{border-left-color:#d98b80;} .wlog .msg{background:#262219;border-left:3px solid #e0c07a;} .wlog .think{color:#a89f8c;font-style:italic;} .wlog .edit{background:#2b271f;border-left:3px solid #7aa7d9;} .wlog .res{background:#2f2a1f;border-left:3px solid #e0c07a;font-weight:600;} .wlog .raw{color:#d98b80;} .wlog .out{color:#b8ae99;display:block;margin-top:4px;max-height:180px;overflow:auto;} .wlog-bar{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap;} '+
5268
+ '.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;}}' +
4797
5269
  '</style>' +
4798
5270
  '</head><body>' +
4799
5271
  '<main>' +
@@ -4831,6 +5303,9 @@ function renderDashboardHtml(snapshot) {
4831
5303
  '<div class="section-grid">' +
4832
5304
  '<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
4833
5305
  '</div>' +
5306
+ '<div class="section-grid">' +
5307
+ '<section class="panel"><h2>Ticket Workers</h2><div id="worker-section" class="accordion"></div></section>' +
5308
+ '</div>' +
4834
5309
  '<div class="section-grid">' +
4835
5310
  '<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
4836
5311
  '</div>' +
@@ -4864,15 +5339,16 @@ function renderDashboardHtml(snapshot) {
4864
5339
  'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
4865
5340
  'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
4866
5341
  '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>\';}' +
4867
- 'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const agentState=state.agents||{};const agentList=agentState.registered||[];const idleAgents=agentList.filter(function(a){return (a.state||"idle")==="idle";}).length;const cards=[' +
5342
+ '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 ticketParts=ticketState.compartments||[];const ticketTotal=ticketParts.reduce(function(s,p){return s+Number(p.open||0);},0);const agentState=state.agents||{};const agentList=agentState.registered||[];const idleAgents=agentList.filter(function(a){return (a.state||"idle")==="idle";}).length;const cards=[' +
4868
5343
  '{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")},' +
4869
5344
  '{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")},' +
4870
- '{title:"Tickets",value:String(ticketTotal),label:"Open support queue",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":"Fetched "+String(ticketState.queueCount||0)+" queue(s)"},' +
4871
- '{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent register"},' +
5345
+ '{title:"Tickets",value:String(ticketTotal),label:"Open, all platforms and lanes",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":(ticketParts.length>1?ticketParts.map(function(p){return String(p.open||0)+" "+(p.dataMode||"prod");}).join(" · "):"One compartment")},' +
5346
+ '{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent serve"},' +
5347
+ '{title:"Workers",value:String(((state.agents||{}).workers||[]).length),label:"Autonomous ticket runs",tone:((state.agents||{}).workers||[]).length?"ok":"warn",sub:((state.agents||{}).workers||[]).length?"One headless process per ticket":"Nothing running. Start with: remits-cli agent serve"},' +
4872
5348
  '{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"},' +
4873
5349
  '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
4874
5350
  '];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("");}' +
4875
- 'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const ticketCounts=(state.supportTickets||{}).counts||{};document.getElementById("summary-grid").innerHTML=[' +
5351
+ 'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const ticketParts2=(state.supportTickets||{}).compartments||[];const ticketCounts={routed:ticketParts2.reduce(function(s,p){return s+Number(p.routed||0);},0)};document.getElementById("summary-grid").innerHTML=[' +
4876
5352
  'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
4877
5353
  'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
4878
5354
  'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
@@ -4884,19 +5360,25 @@ function renderDashboardHtml(snapshot) {
4884
5360
  '].join("");}' +
4885
5361
  '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>\':"");}' +
4886
5362
  '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("");}' +
4887
- 'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];const counts=support.counts||{};let html=\'<div class="mini-grid"><div class="mini-card"><strong>Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Routed To An Agent</strong>\'+escapeHtml(String(counts.routed||0))+\'</div><div class="mini-card"><strong>Waiting</strong>\'+escapeHtml(String(counts.unrouted==null?Math.max(0,(support.totalCount||0)-(counts.routed||0)):counts.unrouted))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}if(tickets.length){html+=tickets.map(function(ticket,index){const routed=!!ticket.routedAgentId;const meta=badge(ticket.priority||"priority",ticket.priority==="critical"||ticket.priority==="high"?"bad":"warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' \'+badge(routed?("routed: "+(ticket.routedAgentLabel||ticket.routedAgentId)):"unrouted",routed?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(state,ticket,"support-ticket:"+index),index===0);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned.</div>\';}target.innerHTML=html;}' +
4888
- 'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
5363
+ 'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const parts=support.compartments||[];const errors=support.errors||[];let html="";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(!parts.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}html+=parts.map(function(part,pi){const lane=(part.dataMode||"prod");const meta=badge(lane+" lane",lane==="test"?"warn":"ok")+\' \'+badge(String(part.open||0)+" open","ok")+\' <span class="muted">\'+escapeHtml(String(part.queueCount||0))+\' account queue(s)\'+(part.overlappingQueuesDropped?", "+part.overlappingQueuesDropped+" overlapping dropped":"")+\'</span>\';const caveat=part.scopeUnknown?\'<div class="mini-card"><strong>Note</strong>A queue did not report which accounts it scanned, so this total may count a ticket more than once.</div>\':"";const cards=\'<div class="mini-grid"><div class="mini-card"><strong>Open</strong>\'+escapeHtml(String(part.open||0))+\'</div><div class="mini-card"><strong>Routed To An Agent</strong>\'+escapeHtml(String(part.routed||0))+\'</div><div class="mini-card"><strong>Unrouted</strong>\'+escapeHtml(String(part.unrouted||0))+\'</div><div class="mini-card"><strong>Awaiting A Reply</strong>\'+escapeHtml(String(part.awaitingReplyOnPage||0))+\' <span class="muted">of \'+escapeHtml(String((part.tickets||[]).length))+\' shown</span></div></div>\'+caveat;const rows=(part.tickets||[]).length?(part.tickets||[]).map(function(ticket,index){const routed=!!ticket.routedAgentId;const waiting=!!ticket.awaitingResponseSince;const meta2=badge(ticket.priority||"priority",ticket.priority==="critical"||ticket.priority==="high"?"bad":"warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' \'+badge(routed?("routed: "+(ticket.routedAgentLabel||ticket.routedAgentId)):"unrouted",routed?"ok":"warn")+(waiting?\' \'+badge("awaiting reply","warn"):"")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+pi+":"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta2,supportTicketDetails(state,ticket,"support-ticket:"+pi+":"+index),false);}).join(""):\'<div class="empty">No open support tickets in this compartment.</div>\';return detailsBlock("compartment:"+part.compartment,part.baseUrl||part.compartment,meta,cards+\'<div class="accordion">\'+rows+\'</div>\',pi===0);}).join("");html+=\'<div class="muted" style="margin-top:10px;">Each platform and data lane is counted separately. A localhost test-lane queue is full of suite fixtures and is not comparable to a production queue — summing them is what made this panel report 583 open tickets against a production queue of 24.</div>\';target.innerHTML=html;}' +
5364
+ 'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){if(ticket.baseUrl&&a.baseUrl&&a.baseUrl!==ticket.baseUrl)return false;if(ticket.dataMode&&a.dataMode&&a.dataMode!==ticket.dataMode)return false;return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" data-route-base="\'+escapeHtml(ticket.baseUrl||"")+\'" data-route-mode="\'+escapeHtml(ticket.dataMode||"")+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const lane=ticket.dataMode||"prod";const planning=[ticket.workstream?("Workstream "+ticket.workstream):"",ticket.plannedIn?("Planned "+ticket.plannedIn):"",ticket.boardStage?("Board "+ticket.boardStage):"",ticket.rank!==undefined&&ticket.rank!==null&&ticket.rank!==""?("Rank "+ticket.rank):"",ticket.size?("Size "+ticket.size):"",ticket.blockedBy?("Blocked by "+ticket.blockedBy):""].filter(Boolean).join(" / ");const command="remits-cli ticket read --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane+"\\nremits-cli ticket accept --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane;return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Planning</strong>\'+escapeHtml(planning||"No planning fields")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
4889
5365
  '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("");}' +
4890
5366
  'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
4891
- 'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
5367
+ 'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];if(!workers.length){target.innerHTML=\'<div class="empty">No autonomous ticket workers are running. Start one with <strong>remits-cli agent serve</strong> — it launches a fresh headless agent process per routed ticket, so no session accumulates context across tickets.</div>\';return;}target.innerHTML=workers.map(function(w,index){const meta=badge(w.agentType||"agent",w.alive?"ok":"bad")+\' \'+badge(w.alive?"running":"exited",w.alive?"ok":"bad")+\' <span class="muted">attempt \'+escapeHtml(String(w.attempt||1))+\'</span>\';const t=escapeHtml(String(w.ticketId||""));const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(String(w.ticketId||"?"))+\'</div><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(w.activity||"nothing reported yet")+\'</div><div class="mini-card"><strong>Started</strong>\'+escapeHtml(formatTime(w.startedAt))+\'</div><div class="mini-card"><strong>Pid</strong>\'+escapeHtml(String(w.pid||"?"))+\'</div><div class="mini-card"><strong>Repo</strong>\'+escapeHtml(w.workingDirectory||"(none resolved)")+\'</div><div class="mini-card"><strong>Supervisor</strong>\'+escapeHtml(w.label||w.agentId||"?")+\'</div></div><div class="wlog-bar"><button class="action-btn" data-follow-ticket="\'+t+\'">Follow live</button><label class="muted"><input type="checkbox" data-follow-tail="\'+t+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+t+\'"></span></div><div class="wlog" data-follow-log="\'+t+\'"><div class="muted">Press <strong>Follow live</strong> to stream this run.</div></div>\'+codeBlock("wlog-tail:"+t,"tail -f "+(w.logFile||""))+\'</div>\';return detailsBlock("worker:"+w.ticketId,"Ticket "+w.ticketId+" - "+(w.subject||"(no subject)"),meta,body,index===0);}).join("");restartActiveFollows();}' +
5368
+ 'var followState={};' +
5369
+ 'function eventHtml(ev){if(!ev)return "";const t=escapeHtml(ev.text||"");if(ev.kind==="command"){const bad=(ev.exitCode!==null&&ev.exitCode!==undefined&&ev.exitCode!==0);const out=ev.detail?\'<span class="out">\'+escapeHtml(String(ev.detail).slice(0,2000))+\'</span>\':"";return \'<div class="ev cmd\'+(bad?" bad":"")+\'">$ \'+t+(bad?\' <span class="muted">(exit \'+escapeHtml(String(ev.exitCode))+\')</span>\':"")+out+\'</div>\';}if(ev.kind==="message")return \'<div class="ev msg">\'+t+\'</div>\';if(ev.kind==="thinking")return \'<div class="ev think">\'+t+\'</div>\';if(ev.kind==="edit")return \'<div class="ev edit">\'+t+\'</div>\';if(ev.kind==="result")return \'<div class="ev res">\'+t+\'</div>\';if(ev.kind==="raw")return \'<div class="ev raw">\'+t+\'</div>\';return \'<div class="ev">\'+t+\'</div>\';}' +
5370
+ 'async function pollFollow(ticket){const st=followState[ticket];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+ticket+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+ticket+\'"]\');if(!box){return;}try{const r=await fetch("/api/worker-log?ticket="+encodeURIComponent(ticket)+"&offset="+st.offset);const d=await r.json();if(d.missing){status&&(status.textContent="no transcript yet");}else{if(st.offset===0)box.innerHTML="";st.offset=d.offset||st.offset;const html=(d.events||[]).map(eventHtml).join("");if(html){box.insertAdjacentHTML("beforeend",html);const stick=document.querySelector(\'[data-follow-tail="\'+ticket+\'"]\');if(!stick||stick.checked)box.scrollTop=box.scrollHeight;}status&&(status.textContent=(d.running?"live":"finished")+" - "+(d.size||0)+" bytes");if(!d.running&&d.offset>=d.size){st.settleCount=(st.settleCount||0)+1;if(st.settleCount>2){clearInterval(st.timer);delete followState[ticket];return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
5371
+ 'function startFollow(ticket){if(followState[ticket]){clearInterval(followState[ticket].timer);}followState[ticket]={offset:0,timer:null};pollFollow(ticket);followState[ticket].timer=setInterval(function(){pollFollow(ticket);},2000);}' +
5372
+ 'function restartActiveFollows(){Object.keys(followState).forEach(function(t){const box=document.querySelector(\'[data-follow-log="\'+t+\'"]\');if(!box){clearInterval(followState[t].timer);delete followState[t];return;}const st=followState[t];st.offset=0;box.innerHTML="";pollFollow(t);});}' +
5373
+ 'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">\'+escapeHtml(agent.baseUrl||"?")+\' · seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
4892
5374
  'function renderActivity(state){const target=document.getElementById("activity-section");const logFile=(state.globalFiles||[]).find(function(file){return file.label==="activity.log (tail)";});target.innerHTML=detailsBlock("activity:log","Activity tail","service lifecycle, websocket, and agent events",codeBlock("activity-log",logFile&&logFile.content||"[missing]","log"),true);}' +
4893
5375
  'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
4894
5376
  '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("");}' +
4895
- 'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderAgents(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
5377
+ 'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderAgents(state);renderWorkers(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
4896
5378
  '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();}' +
4897
5379
  '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);}' +
4898
5380
  'async function triggerAction(action,button,extra){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{let body="action="+encodeURIComponent(action);Object.keys(extra||{}).forEach(function(k){body+="&"+encodeURIComponent(k)+"="+encodeURIComponent(extra[k]==null?"":extra[k]);});const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
4899
- 'document.addEventListener("click",function(event){const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value};}triggerAction(action,button,extra);});' +
5381
+ 'document.addEventListener("click",function(event){const follow=event.target.closest?event.target.closest("[data-follow-ticket]"):null;if(follow){startFollow(follow.getAttribute("data-follow-ticket"));return;}const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value,baseUrl:select.getAttribute("data-route-base"),dataMode:select.getAttribute("data-route-mode")};}triggerAction(action,button,extra);});' +
4900
5382
  'renderAll(dashboardState);' +
4901
5383
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
4902
5384
  '})();' +
@@ -4929,6 +5411,172 @@ async function reconnectManagedWebsockets() {
4929
5411
  }
4930
5412
  }
4931
5413
 
5414
+ /**
5415
+ * Turn a worker's raw JSONL transcript into readable events for the dashboard.
5416
+ *
5417
+ * <p>A worker has no terminal to attach to — it is spawned with pipes, deliberately, and `codex exec`
5418
+ * / `claude -p` are non-interactive, so there is nothing to type at even if there were a tty. What a
5419
+ * reader actually wants is to FOLLOW ALONG, and for that the JSONL is strictly better than a terminal
5420
+ * would be: it is already structured, so commands, their exit codes, file changes and the agent's own
5421
+ * messages can be rendered as distinct things instead of scraped back out of ANSI output.</p>
5422
+ *
5423
+ * <p>Reads incrementally from a byte offset so the browser polls for the tail rather than re-fetching
5424
+ * a transcript that grows to megabytes.</p>
5425
+ */
5426
+ function readWorkerTranscript(logFile, fromOffset = 0) {
5427
+ const empty = { events: [], offset: 0, size: 0, missing: true };
5428
+ if (!logFile || !fs.existsSync(logFile)) {
5429
+ return empty;
5430
+ }
5431
+ // Only ever serve files from the worker log directory. The dashboard binds to 127.0.0.1, but a path
5432
+ // taken from a query string and handed to readFileSync is a file-read primitive regardless.
5433
+ const resolved = path.resolve(logFile);
5434
+ if (path.dirname(resolved) !== path.resolve(WORKER_LOG_DIR)) {
5435
+ return { ...empty, missing: false, forbidden: true };
5436
+ }
5437
+
5438
+ const size = fs.statSync(resolved).size;
5439
+ // A truncated or rotated file (size went backwards) must restart, not read from a stale offset.
5440
+ const start = Math.min(Math.max(0, Number(fromOffset) || 0), size);
5441
+ if (start >= size) {
5442
+ return { events: [], offset: size, size, missing: false };
5443
+ }
5444
+
5445
+ const fd = fs.openSync(resolved, 'r');
5446
+ let text = '';
5447
+ try {
5448
+ const length = size - start;
5449
+ const buffer = Buffer.alloc(length);
5450
+ fs.readSync(fd, buffer, 0, length, start);
5451
+ text = buffer.toString('utf8');
5452
+ } finally {
5453
+ fs.closeSync(fd);
5454
+ }
5455
+
5456
+ // Only whole lines are consumed; a partial trailing line is left for the next poll by rewinding the
5457
+ // reported offset, so a JSON object split across two reads is never dropped as unparseable.
5458
+ const lines = text.split('\n');
5459
+ const trailing = lines.pop();
5460
+ const consumed = size - Buffer.byteLength(trailing, 'utf8');
5461
+
5462
+ const events = [];
5463
+ for (const line of lines) {
5464
+ if (!line.trim()) continue;
5465
+ // describeWorkerEvent returns null for the bookkeeping frames a reader does not want
5466
+ // (thread.started, todo_list, …). Dropping them here keeps the payload a clean list.
5467
+ const described = describeWorkerEvent(line);
5468
+ if (described) {
5469
+ events.push(described);
5470
+ }
5471
+ }
5472
+ return { events, offset: consumed, size, missing: false };
5473
+ }
5474
+
5475
+ /** One transcript line as {kind, text, detail} — never throws, and never drops a line it cannot parse. */
5476
+ function describeWorkerEvent(line) {
5477
+ let event = null;
5478
+ try {
5479
+ event = JSON.parse(line);
5480
+ } catch (_) {
5481
+ // Not JSON: a plain stderr line from the agent or the shell. Worth showing verbatim — the times
5482
+ // this matters most (a crash, a missing binary) are exactly the times it is not JSON.
5483
+ return { kind: 'raw', text: line.slice(0, 4000) };
5484
+ }
5485
+ if (!event || typeof event !== 'object') {
5486
+ return { kind: 'raw', text: line.slice(0, 4000) };
5487
+ }
5488
+
5489
+ if (event.type === 'item.completed' && event.item) {
5490
+ const item = event.item;
5491
+ const kind = item.item_type || item.type;
5492
+ if (kind === 'command_execution') {
5493
+ return {
5494
+ kind: 'command',
5495
+ text: String(item.command || ''),
5496
+ exitCode: item.exit_code == null ? null : Number(item.exit_code),
5497
+ detail: String(item.aggregated_output || '').slice(0, 4000)
5498
+ };
5499
+ }
5500
+ if (kind === 'file_change') {
5501
+ return { kind: 'edit', text: 'edited files', detail: JSON.stringify(item.changes || item, null, 2).slice(0, 4000) };
5502
+ }
5503
+ if (kind === 'reasoning') return { kind: 'thinking', text: String(item.text || '').slice(0, 4000) };
5504
+ if (kind === 'agent_message') return { kind: 'message', text: String(item.text || '').slice(0, 8000) };
5505
+ if (kind === 'todo_list') return null;
5506
+ }
5507
+ if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
5508
+ for (const block of event.message.content) {
5509
+ if (block && block.type === 'tool_use') {
5510
+ const input = block.input || {};
5511
+ return {
5512
+ kind: 'command',
5513
+ text: String(block.name || 'tool') + (input.command || input.file_path || input.pattern
5514
+ ? ': ' + (input.command || input.file_path || input.pattern) : ''),
5515
+ detail: JSON.stringify(input, null, 2).slice(0, 4000)
5516
+ };
5517
+ }
5518
+ if (block && block.type === 'text' && block.text) {
5519
+ return { kind: 'message', text: String(block.text).slice(0, 8000) };
5520
+ }
5521
+ }
5522
+ }
5523
+ if (event.type === 'result') {
5524
+ return { kind: 'result', text: 'finished: ' + (event.subtype || 'done'), detail: String(event.result || '').slice(0, 4000) };
5525
+ }
5526
+ if (event.type === 'turn.completed' && event.usage) {
5527
+ return { kind: 'result', text: 'turn complete', detail: JSON.stringify(event.usage) };
5528
+ }
5529
+ // Bookkeeping frames a reader gains nothing from. `item.started` in particular is paired with an
5530
+ // `item.completed` carrying the same command plus its output and exit code, so showing both doubles
5531
+ // the transcript and puts every command on screen twice.
5532
+ if (event.type === 'thread.started' || event.type === 'turn.started' ||
5533
+ event.type === 'item.started' || event.type === 'item.updated' || event.type === 'system') {
5534
+ return null;
5535
+ }
5536
+ return { kind: 'event', text: String(event.type || 'event') };
5537
+ }
5538
+
5539
+ /** The transcript of one running or finished worker, addressed by its ticket. */
5540
+ function workerTranscriptFor(ticketId, fromOffset) {
5541
+ const id = String(ticketId || '').trim();
5542
+ if (!id) {
5543
+ return { error: 'a ticketId is required' };
5544
+ }
5545
+ let worker = null;
5546
+ for (const entry of listLocalAgents()) {
5547
+ const found = listWorkers(entry.agentId).find((w) => String(w.ticketId) === id);
5548
+ if (found) {
5549
+ worker = { ...found, agentId: entry.agentId };
5550
+ break;
5551
+ }
5552
+ }
5553
+ // A worker that has EXITED is gone from the store but its transcript is the most interesting one to
5554
+ // read, so fall back to the newest log file for that ticket.
5555
+ let logFile = worker && worker.logFile;
5556
+ if (!logFile && fs.existsSync(WORKER_LOG_DIR)) {
5557
+ const candidates = fs.readdirSync(WORKER_LOG_DIR)
5558
+ .filter((name) => name.startsWith(id + '-') && name.endsWith('.log'))
5559
+ .sort();
5560
+ if (candidates.length) {
5561
+ logFile = path.join(WORKER_LOG_DIR, candidates[candidates.length - 1]);
5562
+ }
5563
+ }
5564
+ if (!logFile) {
5565
+ return { ticketId: id, missing: true, events: [], offset: 0 };
5566
+ }
5567
+ const transcript = readWorkerTranscript(logFile, fromOffset);
5568
+ return {
5569
+ ticketId: id,
5570
+ logFile,
5571
+ running: !!(worker && worker.pid && processIsAlive(worker.pid)),
5572
+ pid: worker ? worker.pid : null,
5573
+ agentType: worker ? worker.agentType : null,
5574
+ startedAt: worker ? worker.startedAt : null,
5575
+ follow: 'tail -f ' + logFile,
5576
+ ...transcript
5577
+ };
5578
+ }
5579
+
4932
5580
  function startDashboardServer(preferredPort) {
4933
5581
  return new Promise((resolve, reject) => {
4934
5582
  const sendDashboardError = (res, err, wantsJson = true) => {
@@ -4992,7 +5640,9 @@ function startDashboardServer(preferredPort) {
4992
5640
  actionResult = await routeTicketFromDashboard({
4993
5641
  ticketId: params.get('ticketId'),
4994
5642
  accountId: params.get('accountId'),
4995
- agentId: params.get('agentId')
5643
+ agentId: params.get('agentId'),
5644
+ baseUrl: params.get('baseUrl'),
5645
+ dataMode: params.get('dataMode')
4996
5646
  });
4997
5647
  supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
4998
5648
  agentSnapshotCache = { fetchedAtMs: 0, value: null };
@@ -5018,6 +5668,20 @@ function startDashboardServer(preferredPort) {
5018
5668
  return;
5019
5669
  }
5020
5670
 
5671
+ if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
5672
+ try {
5673
+ const payload = workerTranscriptFor(
5674
+ requestUrl.searchParams.get('ticket'),
5675
+ Number(requestUrl.searchParams.get('offset')) || 0);
5676
+ res.statusCode = 200;
5677
+ res.setHeader('Content-Type', 'application/json');
5678
+ res.end(JSON.stringify(payload));
5679
+ } catch (err) {
5680
+ sendDashboardError(res, err, true);
5681
+ }
5682
+ return;
5683
+ }
5684
+
5021
5685
  if (req.method === 'GET' && requestUrl.pathname === '/') {
5022
5686
  try {
5023
5687
  const snapshot = await collectDashboardSnapshot();
@@ -5451,45 +6115,409 @@ const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5
5451
6115
  const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
5452
6116
  const AGENT_STATES = ['idle', 'working', 'paused'];
5453
6117
 
5454
- function readAgentStore() {
5455
- if (!fs.existsSync(AGENT_STATE_FILE)) {
5456
- return { agents: {} };
6118
+ // ---------------------------------------------------------------------------------------------
6119
+ // Autonomous ticket workers
6120
+ //
6121
+ // A registered agent can also SERVE: a detached supervisor polls for the tickets routed to this
6122
+ // session and launches ONE fresh headless agent process per ticket.
6123
+ //
6124
+ // One ticket = one process is the whole point, and it is what makes context grooming structural
6125
+ // rather than a discipline. `/clear` and `/new` are TUI commands a human types; a model cannot
6126
+ // invoke them, so a long-lived interactive session working ticket after ticket has no way to reset
6127
+ // itself. A process that exits has nothing to reset.
6128
+ //
6129
+ // The supervisor is deliberately dumb: it starts workers, reports what they are doing, and cleans
6130
+ // up after them. Every decision that must survive it — which session a ticket is routed to, which
6131
+ // worker holds it — is durable on the platform, so killing the supervisor loses nothing but time.
6132
+ // ---------------------------------------------------------------------------------------------
6133
+
6134
+ const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
6135
+ const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
6136
+ const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
6137
+ // After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
6138
+ // should not hold a request-per-20s against production for work that arrives twice a day.
6139
+ const AGENT_SERVE_IDLE_AFTER_POLLS = 10;
6140
+ const AGENT_WORKER_MAX_ATTEMPTS = Number(process.env.REMITS_CLI_AGENT_WORKER_MAX_ATTEMPTS || 2);
6141
+ const AGENT_WORKER_STATUS_THROTTLE_MS = 20000;
6142
+ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
6143
+
6144
+ /**
6145
+ * How each supported agent is invoked headlessly, and how far it is allowed to go.
6146
+ *
6147
+ * Every one of them takes the brief on STDIN rather than in argv: a brief is a page of prose and
6148
+ * argv has a hard length limit, so passing it as an argument fails on exactly the detailed tickets
6149
+ * that most need the detail.
6150
+ *
6151
+ * `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
6152
+ * read-only mode so an operator can put a real boundary around a production support agent; `edit`
6153
+ * lets it change files but never commits (the brief says so, and nothing here runs git).
6154
+ */
6155
+ const AGENT_LAUNCHERS = {
6156
+ codex: {
6157
+ binary: 'codex',
6158
+ // A ticket worker's whole job runs THROUGH the platform: `remits-cli ticket read/accept/progress/
6159
+ // complete`, and every investigation tool. So the sandbox has to let it reach the platform, and by
6160
+ // default it does not — `workspace-write` disables network, and a worker launched without this flag
6161
+ // fails its FIRST command with exit 6 / HTTP 000 while looking, from the outside, like an agent that
6162
+ // simply did not do much. Passed on the command line rather than documented as a config edit so the
6163
+ // supervisor works on a machine nobody has prepared.
6164
+ //
6165
+ // `read-only` is deliberately NOT used for investigate mode, even though it is the tighter sandbox.
6166
+ // It has no network AND no setting that restores it, so a read-only worker cannot read the ticket it
6167
+ // was given, cannot run a diagnostic, and cannot record a finding — the three things investigating
6168
+ // IS. It would then exit 0 having written nothing, which the supervisor reads as a delivered
6169
+ // judgement and un-routes, so the next sweep hands the same ticket to another worker that learns the
6170
+ // same nothing. A loop that reports success is worse than a refusal.
6171
+ //
6172
+ // What enforces the read-only phase instead: the brief's Boundaries section, which names the lease
6173
+ // holder, says which commands not to run, and explains that the shared CHECKOUT is the thing at
6174
+ // risk. Enforcement moved from the sandbox to the instructions deliberately — the sandbox could not
6175
+ // express "may talk to the platform, may not write this directory".
6176
+ build: ({ cwd, mode, lastMessageFile }) => ({
6177
+ args: [
6178
+ 'exec',
6179
+ '--cd', cwd,
6180
+ '--skip-git-repo-check',
6181
+ '--sandbox', 'workspace-write',
6182
+ '-c', 'sandbox_workspace_write.network_access=true',
6183
+ '--json',
6184
+ ...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
6185
+ '-'
6186
+ ],
6187
+ // Reported on the worker record so an operator can tell a phase the platform derived from a
6188
+ // sandbox the launcher applied — they are no longer the same thing for codex.
6189
+ sandbox: 'workspace-write' + (mode === 'investigate' ? ' (investigate: boundary is the brief)' : '')
6190
+ })
6191
+ },
6192
+ claude: {
6193
+ binary: 'claude',
6194
+ // Same requirement, different mechanism: in `-p` there is no one to answer a permission prompt, so
6195
+ // an un-allowlisted Bash call is DENIED rather than queued. Without this the worker cannot run
6196
+ // remits-cli at all. `plan` mode is avoided for the same reason `read-only` is avoided above.
6197
+ build: ({ cwd, mode }) => ({
6198
+ args: [
6199
+ '-p',
6200
+ '--add-dir', cwd,
6201
+ '--output-format', 'stream-json',
6202
+ '--verbose',
6203
+ '--permission-mode', 'acceptEdits',
6204
+ '--allowedTools', 'Bash,Read,Grep,Glob,WebFetch' + (mode === 'investigate' ? '' : ',Edit,Write')
6205
+ ],
6206
+ sandbox: 'acceptEdits' + (mode === 'investigate' ? ' (investigate: no Edit/Write tool)' : '')
6207
+ })
6208
+ },
6209
+ gemini: {
6210
+ binary: 'gemini',
6211
+ // gemini's -p takes the prompt as a value and APPENDS it to stdin, so the brief still travels on
6212
+ // stdin and -p carries only the instruction to act on it.
6213
+ build: ({ mode }) => ({
6214
+ args: [
6215
+ '-p', 'Work the support ticket described above, following its instructions exactly.',
6216
+ '--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
6217
+ '--output-format', 'stream-json'
6218
+ ],
6219
+ sandbox: mode === 'investigate' ? 'plan' : 'auto_edit'
6220
+ })
5457
6221
  }
5458
- try {
5459
- const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
5460
- return data && typeof data === 'object' && data.agents ? data : { agents: {} };
5461
- } catch (_) {
5462
- return { agents: {} };
6222
+ };
6223
+
6224
+ /**
6225
+ * Which supported agent IS this process, judged from the anchor's command line.
6226
+ *
6227
+ * The binary name leads: an agent's command line routinely mentions another agent's name in a path
6228
+ * (`/Users/me/.codex/skills/...` while running claude), so a plain substring scan over the whole line
6229
+ * picks the wrong one. Returns null for an anchor that is not a supported worker — `cursor-agent` and
6230
+ * `aider` can anchor a session but cannot be launched as workers — so the caller falls back rather
6231
+ * than guessing.
6232
+ */
6233
+ function detectAgentKind(command) {
6234
+ const line = String(command || '').trim();
6235
+ if (!line) {
6236
+ return null;
6237
+ }
6238
+ const binary = path.basename(line.split(/\s+/)[0] || '').toLowerCase();
6239
+ const exact = SUPPORTED_AGENTS.find((agent) => binary === agent || binary.startsWith(agent + '-'));
6240
+ if (exact) {
6241
+ return exact;
5463
6242
  }
6243
+ return SUPPORTED_AGENTS.find((agent) => new RegExp('(^|[/\\s])' + agent + '($|[\\s])', 'i').test(line)) || null;
5464
6244
  }
5465
6245
 
5466
- function writeAgentStore(store) {
5467
- ensureSessionDir();
5468
- atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
6246
+ /**
6247
+ * Which agent a worker should be launched as.
6248
+ *
6249
+ * Precedence: an explicit flag, then WHAT THIS SESSION ACTUALLY IS, then the configured preference,
6250
+ * then the default. The anchor beats the config because it is specific to this session, and because
6251
+ * the alternative is the surprise this ordering exists to remove: `agent serve` in a Claude tab
6252
+ * labelling itself `claude@repo` while launching codex workers, since the label already detected
6253
+ * from the anchor and only the worker choice read the config. One question, one answer.
6254
+ */
6255
+ function workerAgentType(value, anchor = null) {
6256
+ return resolveWorkerAgent(value, anchor).agent;
5469
6257
  }
5470
6258
 
5471
- function processIsAlive(pid) {
5472
- const numeric = Number(pid);
5473
- if (!Number.isFinite(numeric) || numeric <= 0) {
5474
- return false;
6259
+ /**
6260
+ * The worker choice AND why it was made.
6261
+ *
6262
+ * The reason is reported, not just the value, because the intended host for a supervisor is a plain
6263
+ * terminal tab — where nothing in the process tree is an AI agent, so the choice comes from a config
6264
+ * file the operator may have set months ago and forgotten. "codex" on its own is indistinguishable
6265
+ * from a default; "codex (from your ~/.remits-cli/config.json)" is a fact they can act on.
6266
+ */
6267
+ function resolveWorkerAgent(value, anchor = null) {
6268
+ const candidate = String(value || '').trim().toLowerCase();
6269
+ if (SUPPORTED_AGENTS.includes(candidate)) {
6270
+ return { agent: candidate, source: 'flag', why: 'you passed --worker-agent' };
5475
6271
  }
5476
- try {
5477
- process.kill(numeric, 0);
5478
- return true;
5479
- } catch (err) {
5480
- // EPERM means the process exists but belongs to another user — still alive.
5481
- return err && err.code === 'EPERM';
6272
+ const detected = detectAgentKind(anchor && anchor.command);
6273
+ if (detected) {
6274
+ return { agent: detected, source: 'session', why: 'this session is running ' + detected };
6275
+ }
6276
+ const config = readConfig();
6277
+ if (SUPPORTED_AGENTS.includes(String(config.agent || '').toLowerCase())) {
6278
+ return {
6279
+ agent: String(config.agent).toLowerCase(),
6280
+ source: 'config',
6281
+ why: 'from ' + CONFIG_FILE + ' — override per run with --worker-agent'
6282
+ };
5482
6283
  }
6284
+ return {
6285
+ agent: DEFAULT_AGENT,
6286
+ source: 'default',
6287
+ why: 'nothing configured and no agent in this session — pass --worker-agent to choose'
6288
+ };
5483
6289
  }
5484
6290
 
5485
6291
  /**
5486
- * The locally-known agent sessions, dropping any whose anchor process has gone.
6292
+ * Is this directory a real checkout, rather than something that merely contains an account-info.json?
5487
6293
  *
5488
- * The store is a local convenience so a follow-up command in the same tab resolves the same agent
5489
- * without being told its id. It is never the source of truth: the platform's registry is.
6294
+ * <p>The index is built by scanning for `account-info.json`, and that file is also in every zip export
6295
+ * of an account. An unpacked export in `~/Downloads` therefore looks exactly like a checkout, and it
6296
+ * exists — which was the whole of the previous validation.</p>
5490
6297
  */
5491
- function listLocalAgents() {
5492
- const store = readAgentStore();
6298
+ function isAccountCheckout(directory) {
6299
+ try {
6300
+ return !!directory && fs.existsSync(path.join(directory, '.git'));
6301
+ } catch (_) {
6302
+ return false;
6303
+ }
6304
+ }
6305
+
6306
+ /**
6307
+ * Where the repository for an account is checked out on THIS machine, or null.
6308
+ *
6309
+ * <p>Null when the entry is not a git checkout, and that is the load-bearing part. The caller's next
6310
+ * step on null is the brief telling the worker "no local checkout was resolved — do not guess", which
6311
+ * is the correct outcome; the previous `fs.existsSync` test let an unpacked zip export in `~/Downloads`
6312
+ * satisfy it instead, so the guard never fired and a worker edited, staged and reported success against
6313
+ * a directory with no remote and no history. Nothing about that run looked wrong. Measured on this
6314
+ * machine: account 11 (11 open tickets) and account 2 both resolved that way.</p>
6315
+ */
6316
+ function repoDirectoryForAccount(accountId) {
6317
+ const wanted = Number(accountId);
6318
+ if (!Number.isFinite(wanted) || wanted <= 0) {
6319
+ return null;
6320
+ }
6321
+ const entry = listAccountRepoEntries(readAccountRepoIndex())
6322
+ .find((candidate) => Number(candidate.accountId) === wanted);
6323
+ if (!entry || !entry.directory || !isAccountCheckout(entry.directory)) {
6324
+ return null;
6325
+ }
6326
+ return entry.directory;
6327
+ }
6328
+
6329
+ /**
6330
+ * Everything wrong with the local account-repo index, as sentences an operator can act on.
6331
+ *
6332
+ * <p>Reported at `agent serve` rather than discovered one ticket at a time: a supervisor about to work a
6333
+ * production queue should be told up front which accounts it cannot work, because the alternative is
6334
+ * finding out per ticket, hours apart, from a brief that says only "no checkout resolved".</p>
6335
+ */
6336
+ function accountRepoIndexProblems(ownCwd = null) {
6337
+ const problems = [];
6338
+ // The account this session is standing in resolves from its OWN directory, not from the shared index,
6339
+ // so an ambiguity there is not a problem for this supervisor — it is two worktrees working as intended.
6340
+ // Reporting it anyway trains an operator to ignore the warning, and the warning is load-bearing for
6341
+ // every OTHER account, where a silent wrong winner is unrecoverable.
6342
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6343
+ for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
6344
+ const label = 'account ' + entry.accountId + ' (' + (entry.name || 'unnamed') + ')';
6345
+ if (!fs.existsSync(entry.directory)) {
6346
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6347
+ message: label + ': the recorded directory no longer exists — ' + entry.directory });
6348
+ } else if (!isAccountCheckout(entry.directory)) {
6349
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6350
+ message: label + ': ' + entry.directory + ' is not a git checkout (an unpacked export?). ' +
6351
+ 'Tickets for this account will park instead of being worked.' });
6352
+ }
6353
+ if (Array.isArray(entry.ambiguousWith) && entry.ambiguousWith.length &&
6354
+ !(own && own.accountId === Number(entry.accountId))) {
6355
+ problems.push({ accountId: Number(entry.accountId), severity: 'ambiguous',
6356
+ message: label + ': more than one checkout claims this account. Using ' + entry.directory +
6357
+ '; also found ' + entry.ambiguousWith.join(', ') +
6358
+ '. Set the right one with `remits-cli agent repos --account-id ' + entry.accountId +
6359
+ ' --directory <path>`.' });
6360
+ }
6361
+ }
6362
+ return problems;
6363
+ }
6364
+
6365
+ /**
6366
+ * The working directory a ticket should be worked in.
6367
+ *
6368
+ * The implementation account leads when the ticket names one — a defect observed on a CLIENT is
6369
+ * almost always fixed in the PLATFORM/PRODUCT repo above it — then the ticket's own account. When
6370
+ * neither is checked out here the answer is null, and the brief says so explicitly instead of
6371
+ * letting the worker start in whatever directory the supervisor happened to inherit.
6372
+ */
6373
+ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
6374
+ const facts = (ticket && ticket.briefFacts) || {};
6375
+ const candidates = [
6376
+ facts.repoAccountId,
6377
+ ticket && ticket.implementationAccountId,
6378
+ ticket && ticket.accountId
6379
+ ].map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
6380
+
6381
+ // THIS supervisor's own checkout wins, when it is a checkout of one of the candidate accounts.
6382
+ //
6383
+ // The account-repo index is a single global map keyed by accountId, and `agent serve` writes its own
6384
+ // cwd into it — so two supervisors serving two git WORKTREES of one account fight over one entry, and
6385
+ // the second to start silently repoints the first's future workers into its own tree. That is the
6386
+ // shape worktrees exist to prevent: a supervisor must launch its workers where IT is, not where the
6387
+ // machine's last-registered session happened to be. The index stays the answer for every account this
6388
+ // session is not standing in, which is the normal case.
6389
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6390
+ if (own && candidates.includes(own.accountId)) {
6391
+ return own.directory;
6392
+ }
6393
+
6394
+ for (const candidate of candidates) {
6395
+ const dir = repoDirectoryForAccount(candidate);
6396
+ if (dir) {
6397
+ return dir;
6398
+ }
6399
+ }
6400
+ return null;
6401
+ }
6402
+
6403
+ /**
6404
+ * Which account this directory is a checkout OF, read from the directory itself.
6405
+ *
6406
+ * Deliberately not a lookup in the shared index: the whole point is to answer for one process without
6407
+ * consulting a map another process can overwrite. Null when the directory is not a real checkout — an
6408
+ * unpacked zip export carries `account-info.json` too, which is exactly the trap `isAccountCheckout`
6409
+ * exists to close.
6410
+ */
6411
+ function accountCheckoutIdentity(directory) {
6412
+ try {
6413
+ if (!directory || !isAccountCheckout(directory)) {
6414
+ return null;
6415
+ }
6416
+ const info = loadAccountInfo(directory);
6417
+ const accountId = Number(info && accountIdFromAccountInfo(info));
6418
+ if (!Number.isFinite(accountId) || accountId <= 0) {
6419
+ return null;
6420
+ }
6421
+ return { accountId, directory: path.resolve(directory) };
6422
+ } catch (_) {
6423
+ return null;
6424
+ }
6425
+ }
6426
+
6427
+ /**
6428
+ * Turn a worker's streamed output into something an operator can read in one line.
6429
+ *
6430
+ * All three agents emit JSONL, and all three emit a different shape, so this reads defensively and
6431
+ * returns null for anything it does not recognise rather than guessing. A wrong activity line is
6432
+ * worse than none: the dashboard is the only window into a headless run, and it has to be trusted.
6433
+ */
6434
+ function summarizeWorkerEvent(line) {
6435
+ let event = null;
6436
+ try {
6437
+ event = JSON.parse(line);
6438
+ } catch (_) {
6439
+ return null;
6440
+ }
6441
+ if (!event || typeof event !== 'object') {
6442
+ return null;
6443
+ }
6444
+
6445
+ // codex exec --json. The item's kind is spelled `type` on the versions observed and `item_type` in
6446
+ // some published examples, so read both rather than betting on one — a miss here is silent, and the
6447
+ // whole run then reports "nothing reported yet" while working perfectly.
6448
+ if (event.type === 'item.completed' && event.item) {
6449
+ const item = event.item;
6450
+ const kind = item.item_type || item.type;
6451
+ if (kind === 'command_execution' && item.command) {
6452
+ return 'ran: ' + String(item.command).slice(0, 120);
6453
+ }
6454
+ if (kind === 'file_change') {
6455
+ return 'edited files';
6456
+ }
6457
+ if ((kind === 'reasoning' || kind === 'agent_message') && item.text) {
6458
+ return String(item.text).split('\n')[0].slice(0, 120);
6459
+ }
6460
+ }
6461
+
6462
+ // claude / gemini stream-json
6463
+ if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
6464
+ for (const block of event.message.content) {
6465
+ if (block && block.type === 'tool_use' && block.name) {
6466
+ const input = block.input || {};
6467
+ const detail = input.command || input.file_path || input.pattern || input.path || '';
6468
+ return String(block.name + (detail ? ': ' + detail : '')).slice(0, 120);
6469
+ }
6470
+ if (block && block.type === 'text' && block.text) {
6471
+ return String(block.text).split('\n')[0].slice(0, 120);
6472
+ }
6473
+ }
6474
+ }
6475
+ if (event.type === 'result' && event.subtype) {
6476
+ return 'finishing: ' + event.subtype;
6477
+ }
6478
+ return null;
6479
+ }
6480
+
6481
+
6482
+ function readAgentStore() {
6483
+ if (!fs.existsSync(AGENT_STATE_FILE)) {
6484
+ return { agents: {} };
6485
+ }
6486
+ try {
6487
+ const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
6488
+ return data && typeof data === 'object' && data.agents ? data : { agents: {} };
6489
+ } catch (_) {
6490
+ return { agents: {} };
6491
+ }
6492
+ }
6493
+
6494
+ function writeAgentStore(store) {
6495
+ ensureSessionDir();
6496
+ atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
6497
+ }
6498
+
6499
+ function processIsAlive(pid) {
6500
+ const numeric = Number(pid);
6501
+ if (!Number.isFinite(numeric) || numeric <= 0) {
6502
+ return false;
6503
+ }
6504
+ try {
6505
+ process.kill(numeric, 0);
6506
+ return true;
6507
+ } catch (err) {
6508
+ // EPERM means the process exists but belongs to another user — still alive.
6509
+ return err && err.code === 'EPERM';
6510
+ }
6511
+ }
6512
+
6513
+ /**
6514
+ * The locally-known agent sessions, dropping any whose anchor process has gone.
6515
+ *
6516
+ * The store is a local convenience so a follow-up command in the same tab resolves the same agent
6517
+ * without being told its id. It is never the source of truth: the platform's registry is.
6518
+ */
6519
+ function listLocalAgents() {
6520
+ const store = readAgentStore();
5493
6521
  const alive = {};
5494
6522
  let changed = false;
5495
6523
  for (const [agentId, entry] of Object.entries(store.agents || {})) {
@@ -5582,11 +6610,9 @@ function resolveAgentAnchorPid() {
5582
6610
 
5583
6611
  function agentLabelDefault(anchor, cwd) {
5584
6612
  const repo = path.basename(cwd || process.cwd());
5585
- const kind = anchor && anchor.command && /claude/i.test(anchor.command) ? 'claude'
5586
- : anchor && anchor.command && /codex/i.test(anchor.command) ? 'codex'
5587
- : anchor && anchor.command && /gemini/i.test(anchor.command) ? 'gemini'
5588
- : getPreferredAgent();
5589
- return kind + '@' + repo;
6613
+ // Same resolution as the WORKER choice, deliberately: a label that says one agent while the
6614
+ // supervisor launches another is worse than no label, because it is the only thing an operator sees.
6615
+ return workerAgentType(null, anchor) + '@' + repo;
5590
6616
  }
5591
6617
 
5592
6618
  /**
@@ -5717,9 +6743,19 @@ async function agentRegisterCommand(flags, options = {}) {
5717
6743
  host: os.hostname(),
5718
6744
  pid: anchor.pid,
5719
6745
  cwd,
6746
+ // WHERE this session works, not just who it is. One account can now have several checkouts at once
6747
+ // (git worktrees) on different branches staging into different workspace lanes, so the account alone
6748
+ // no longer identifies the tree a worker would edit — and the edit lease refusal, the work map and the
6749
+ // ticket claim all report these back.
6750
+ branch: currentBranch(cwd) || undefined,
6751
+ workspace: resolveWorkspace(cwd, flags) || undefined,
5720
6752
  dataMode,
5721
6753
  accountId: Number(session.accountId) || undefined,
5722
6754
  accounts: agentAccountClaims(cwd),
6755
+ // Capacity is how many workers this session can actually RUN, which only this side knows. The
6756
+ // platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
6757
+ maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
6758
+ autonomous: options.serve ? true : undefined,
5723
6759
  state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
5724
6760
  activity: flags.activity || null,
5725
6761
  source: flags.source || 'repo'
@@ -5748,7 +6784,18 @@ async function agentRegisterCommand(flags, options = {}) {
5748
6784
  // A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
5749
6785
  // re-registering, and the daemons multiply for as long as the platform stays unreachable.
5750
6786
  if (options.spawnHeartbeat !== false) {
5751
- startAgentHeartbeatDaemon(entry);
6787
+ // Remembered so `agent release` can actually STOP it. Without the pid, deregistering only removes
6788
+ // the platform record — and the daemon's very next poll sees registered:false and re-registers,
6789
+ // so the session comes straight back and, when serving, resumes launching workers.
6790
+ const daemonPid = startAgentHeartbeatDaemon(entry, options.serve || null);
6791
+ entry.daemonPid = daemonPid;
6792
+ entry.serving = !!options.serve;
6793
+ if (options.serve) {
6794
+ entry.workerAgent = options.serve.workerAgent;
6795
+ entry.mode = options.serve.mode;
6796
+ entry.maxConcurrent = options.serve.maxConcurrent;
6797
+ }
6798
+ rememberLocalAgent(entry);
5752
6799
  }
5753
6800
 
5754
6801
  if (flagEnabled(flags.json)) {
@@ -5775,14 +6822,170 @@ async function agentRegisterCommand(flags, options = {}) {
5775
6822
  console.log(' --data-mode prod to take real support work.');
5776
6823
  }
5777
6824
  console.log('');
5778
- console.log('You are on duty. The support loop, repeated for as long as you are working:');
6825
+ if (options.serve) {
6826
+ const serve = options.serve;
6827
+ console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
6828
+ console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
6829
+ console.log(' ' + serve.workerAgentWhy);
6830
+ console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
6831
+ console.log('');
6832
+ console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
6833
+ console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
6834
+ console.log('session ever accumulates context across tickets.');
6835
+ console.log('');
6836
+ console.log(' remits-cli agent workers what is running right now');
6837
+ console.log(' remits-cli agent list who else is available');
6838
+ console.log(' remits-cli agent release stop serving and go offline');
6839
+ console.log('');
6840
+ console.log('You do not have to do anything else. This terminal is free.');
6841
+ return;
6842
+ }
6843
+ console.log('You are on duty, but nothing will start on its own. Either:');
6844
+ console.log('');
6845
+ console.log(' remits-cli agent serve # autonomous: a worker process per routed ticket');
6846
+ console.log('');
6847
+ console.log('or work the loop yourself:');
5779
6848
  console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
5780
6849
  console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
5781
6850
  console.log(' 3. work the ticket; keep step 2 current as you change what you are doing');
5782
- console.log(' 4. close it out via mcp_support_ticket (accept -> update_status -> complete)');
6851
+ console.log(' 4. close it out via remits-cli ticket (accept -> status -> complete, ask, or release)');
5783
6852
  console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
5784
6853
  }
5785
6854
 
6855
+ /**
6856
+ * Register this session AND start working tickets autonomously.
6857
+ *
6858
+ * <p>Returns immediately, on purpose. The caller is very often an AI agent inside an interactive
6859
+ * terminal, and a command that blocked would consume the session it was supposed to free — the
6860
+ * supervisor is detached precisely so the tab that started it can go and do something else, or
6861
+ * nothing at all.</p>
6862
+ */
6863
+ async function agentServeCommand(flags) {
6864
+ const choice = resolveWorkerAgent(flags['worker-agent'] || flags.agent, resolveAgentAnchorPid());
6865
+ const workerAgent = choice.agent;
6866
+ const launcher = AGENT_LAUNCHERS[workerAgent];
6867
+ if (!commandExists(launcher.binary)) {
6868
+ throw new Error('Cannot serve with "' + workerAgent + '": the `' + launcher.binary +
6869
+ '` command is not on PATH. Install it, or pick another with --worker-agent ' +
6870
+ SUPPORTED_AGENTS.filter((a) => a !== workerAgent).join('|') + '.');
6871
+ }
6872
+ const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
6873
+ .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
6874
+
6875
+ // Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
6876
+ // pointed at an account with no usable checkout parks correctly, but the operator only learns that
6877
+ // from a brief they may never read, hours later, on one ticket. `--strict-repos` turns it into a
6878
+ // refusal for anyone who would rather not start a production run with known holes.
6879
+ try {
6880
+ updateAccountRepoIndex(process.cwd());
6881
+ discoverAccountRepos();
6882
+ } catch (_) { /* best-effort; the checks below still report what the index does say */ }
6883
+ const problems = accountRepoIndexProblems(process.cwd());
6884
+ if (problems.length) {
6885
+ console.log('');
6886
+ console.log('Account repository index — ' + problems.length + ' issue(s) to know about before serving:');
6887
+ for (const problem of problems) {
6888
+ console.log(' [' + problem.severity + '] ' + problem.message);
6889
+ }
6890
+ console.log(' Tickets for an "unusable" account are parked with an explanation, never worked in the');
6891
+ console.log(' wrong directory. Fix with: remits-cli agent repos --account-id <id> --directory <path>');
6892
+ const ownHere = accountCheckoutIdentity(process.cwd());
6893
+ if (ownHere) {
6894
+ console.log(' Account ' + ownHere.accountId + ' is exempt: this session serves it from its own directory');
6895
+ console.log(' (' + ownHere.directory + '), so a second worktree elsewhere cannot redirect these workers.');
6896
+ }
6897
+ console.log('');
6898
+ if (flagEnabled(flags['strict-repos'])) {
6899
+ throw new Error('Refusing to serve with ' + problems.length +
6900
+ ' account-repository issue(s). Resolve them, or drop --strict-repos to serve anyway.');
6901
+ }
6902
+ }
6903
+
6904
+ return agentRegisterCommand(flags, {
6905
+ serve: {
6906
+ workerAgent,
6907
+ workerAgentWhy: choice.why,
6908
+ mode,
6909
+ maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
6910
+ }
6911
+ });
6912
+ }
6913
+
6914
+ /** Is this binary runnable on this machine? Checked before serving, not on the first ticket. */
6915
+ function commandExists(binary) {
6916
+ // `binary` only ever comes from AGENT_LAUNCHERS, so it is a fixed allowlist rather than user input.
6917
+ try {
6918
+ execFileSync('/bin/sh', ['-c', 'command -v ' + binary], { stdio: ['ignore', 'ignore', 'ignore'] });
6919
+ return true;
6920
+ } catch (_) {
6921
+ return false;
6922
+ }
6923
+ }
6924
+
6925
+ /**
6926
+ * Find this agent's daemon by looking for the process, when its pid was never recorded.
6927
+ *
6928
+ * `daemonPid` is only in the local store for agents registered by a build that writes it. An agent
6929
+ * registered by an EARLIER build has none — and that daemon is precisely the one that cannot be
6930
+ * stopped any other way: it is orphaned (its parent shell is long gone), it re-registers itself on
6931
+ * the next heartbeat after any deregister, and it predates `serve` so it responds to nothing else.
6932
+ * Its own command line carries the agentId, which is enough to find it.
6933
+ */
6934
+ function findAgentDaemonPid(agentId) {
6935
+ if (!agentId) {
6936
+ return null;
6937
+ }
6938
+ try {
6939
+ const listing = execFileSync('ps', ['-ax', '-o', 'pid=,command='], {
6940
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
6941
+ }).toString();
6942
+ for (const line of listing.split('\n')) {
6943
+ if (!line.includes('agent') || !line.includes('heartbeat-daemon') || !line.includes(agentId)) {
6944
+ continue;
6945
+ }
6946
+ const pid = Number((line.trim().match(/^(\d+)/) || [])[1]);
6947
+ // Never signal ourselves: `agent release` run from inside a daemon would kill the command.
6948
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
6949
+ return pid;
6950
+ }
6951
+ }
6952
+ } catch (_) {
6953
+ // ps unavailable or restricted — fall back to the recorded pid only.
6954
+ }
6955
+ return null;
6956
+ }
6957
+
6958
+ /** What this session has running right now, and where to read each run's transcript. */
6959
+ async function agentWorkersCommand(flags) {
6960
+ const agentId = resolveAgentId(flags, { optional: true });
6961
+ if (!agentId) {
6962
+ console.log('No local agent is registered in this session. Run: remits-cli agent serve');
6963
+ return;
6964
+ }
6965
+ const workers = listWorkers(agentId);
6966
+ if (flagEnabled(flags.json)) {
6967
+ console.log(JSON.stringify({ agentId, workers }, null, 2));
6968
+ return;
6969
+ }
6970
+ if (!workers.length) {
6971
+ console.log('No ticket workers are running for ' + agentId + '.');
6972
+ return;
6973
+ }
6974
+ console.log(workers.length + ' worker(s) running:');
6975
+ for (const worker of workers) {
6976
+ const alive = worker.pid && processIsAlive(worker.pid);
6977
+ console.log('');
6978
+ console.log(' ticket ' + worker.ticketId + ' [' + worker.agentType + ', pid ' + worker.pid +
6979
+ (alive ? '' : ', NOT RUNNING') + ']');
6980
+ console.log(' ' + (worker.subject || '(no subject)'));
6981
+ console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
6982
+ console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
6983
+ (worker.workspace ? ' workspace ' + worker.workspace : ''));
6984
+ console.log(' doing ' + (worker.activity || 'nothing reported yet'));
6985
+ console.log(' log ' + worker.logFile);
6986
+ }
6987
+ }
6988
+
5786
6989
  function readCliVersion() {
5787
6990
  try {
5788
6991
  return require(path.join(__dirname, 'package.json')).version || null;
@@ -5799,24 +7002,413 @@ function readCliVersion() {
5799
7002
  * appeared online while it happened to be running remits-cli would be invisible for exactly the
5800
7003
  * period it was busy working a ticket.
5801
7004
  */
5802
- function startAgentHeartbeatDaemon(entry) {
7005
+ function startAgentHeartbeatDaemon(entry, serve = null) {
5803
7006
  const { spawn } = require('child_process');
5804
- const child = spawn(process.execPath, [
7007
+ const args = [
5805
7008
  __filename, 'agent', 'heartbeat-daemon',
5806
7009
  '--agent-id', entry.agentId,
5807
7010
  '--anchor-pid', String(entry.anchorPid),
5808
7011
  '--base-url', entry.baseUrl,
5809
7012
  '--cwd', entry.cwd
5810
- ], { detached: true, stdio: 'ignore', env: { ...process.env } });
7013
+ ];
7014
+ if (serve) {
7015
+ args.push(
7016
+ '--serve', 'true',
7017
+ '--worker-agent', serve.workerAgent,
7018
+ '--mode', serve.mode,
7019
+ '--max-concurrent', String(serve.maxConcurrent));
7020
+ }
7021
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
5811
7022
  child.unref();
5812
7023
  appendGlobalActivityLog('agent', 'heartbeat.started', {
5813
7024
  agentId: entry.agentId,
5814
7025
  anchorPid: entry.anchorPid,
5815
- heartbeatPid: child.pid
7026
+ heartbeatPid: child.pid,
7027
+ serving: !!serve,
7028
+ workerAgent: serve ? serve.workerAgent : null,
7029
+ mode: serve ? serve.mode : null,
7030
+ maxConcurrent: serve ? serve.maxConcurrent : null
5816
7031
  });
5817
7032
  return child.pid;
5818
7033
  }
5819
7034
 
7035
+ const WORKER_STATE_FILE = path.join(SESSION_DIR, 'workers.json');
7036
+
7037
+ function readWorkerStore() {
7038
+ if (!fs.existsSync(WORKER_STATE_FILE)) {
7039
+ return { agents: {} };
7040
+ }
7041
+ try {
7042
+ const data = JSON.parse(fs.readFileSync(WORKER_STATE_FILE, 'utf8'));
7043
+ return data && typeof data === 'object' && data.agents ? data : { agents: {} };
7044
+ } catch (_) {
7045
+ return { agents: {} };
7046
+ }
7047
+ }
7048
+
7049
+ function writeWorkerStore(store) {
7050
+ ensureSessionDir();
7051
+ atomicWriteFile(WORKER_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
7052
+ }
7053
+
7054
+ /**
7055
+ * The workers this agent has running, as a file.
7056
+ *
7057
+ * In memory would be enough for the supervisor itself; on disk is what lets `remits-cli agent
7058
+ * workers` answer from another process, and what lets a restarted supervisor find the claims a
7059
+ * killed one left behind and drop them instead of waiting out the TTL.
7060
+ */
7061
+ function listWorkers(agentId) {
7062
+ const store = readWorkerStore();
7063
+ return Object.values((store.agents || {})[agentId] || {});
7064
+ }
7065
+
7066
+ function recordWorker(agentId, ticketId, patch) {
7067
+ const store = readWorkerStore();
7068
+ store.agents = store.agents || {};
7069
+ store.agents[agentId] = store.agents[agentId] || {};
7070
+ const existing = store.agents[agentId][ticketId] || {};
7071
+ store.agents[agentId][ticketId] = { ...existing, ...patch, ticketId };
7072
+ writeWorkerStore(store);
7073
+ return store.agents[agentId][ticketId];
7074
+ }
7075
+
7076
+ function clearWorker(agentId, ticketId) {
7077
+ const store = readWorkerStore();
7078
+ if (store.agents && store.agents[agentId] && store.agents[agentId][ticketId]) {
7079
+ delete store.agents[agentId][ticketId];
7080
+ writeWorkerStore(store);
7081
+ }
7082
+ }
7083
+
7084
+ /**
7085
+ * Attempts are counted on disk, per ticket, and survive the supervisor.
7086
+ *
7087
+ * Without this a ticket that reliably kills its worker is retried forever: the claim drops on exit,
7088
+ * the next poll sees it routed and unclaimed, and a fresh process starts into the same wall. The
7089
+ * counter is what turns that into a bounded retry ending in an honest release.
7090
+ */
7091
+ function workerAttemptCount(agentId, ticketId) {
7092
+ const store = readWorkerStore();
7093
+ const attempts = ((store.attempts || {})[agentId] || {})[ticketId];
7094
+ return Number(attempts) || 0;
7095
+ }
7096
+
7097
+ function recordWorkerAttempt(agentId, ticketId) {
7098
+ const store = readWorkerStore();
7099
+ store.attempts = store.attempts || {};
7100
+ store.attempts[agentId] = store.attempts[agentId] || {};
7101
+ const next = (Number(store.attempts[agentId][ticketId]) || 0) + 1;
7102
+ store.attempts[agentId][ticketId] = next;
7103
+ writeWorkerStore(store);
7104
+ return next;
7105
+ }
7106
+
7107
+ function clearWorkerAttempts(agentId, ticketId) {
7108
+ const store = readWorkerStore();
7109
+ if (store.attempts && store.attempts[agentId] && store.attempts[agentId][ticketId] != null) {
7110
+ delete store.attempts[agentId][ticketId];
7111
+ writeWorkerStore(store);
7112
+ }
7113
+ }
7114
+
7115
+ /**
7116
+ * The worker's closing summary, recorded on the ticket by the SUPERVISOR.
7117
+ *
7118
+ * <p>A worker is supposed to record what it established itself, with `remits-cli ticket progress`. Most
7119
+ * do. The ones that do not are exactly the runs worth hearing from: a worker that hit its context limit,
7120
+ * that crashed after investigating, or that could not reach the platform at all. Their conclusion was
7121
+ * captured to disk the whole time (`codex exec --output-last-message`) and thrown away, so a ticket came
7122
+ * back to the queue with nothing on it and the next worker rediscovered the same thing.</p>
7123
+ *
7124
+ * <p>Posted only when the run added no worklog entry of its own, so a worker that reported properly is
7125
+ * not duplicated. Marked `worker_summary` rather than `investigation` so a reader can tell a finding the
7126
+ * worker chose to record from a transcript tail the supervisor salvaged.</p>
7127
+ */
7128
+ async function postWorkerSummary(context, agentId, ticket, summaryFile, options = {}) {
7129
+ let text = '';
7130
+ try {
7131
+ text = fs.readFileSync(summaryFile, 'utf8').trim();
7132
+ } catch (_) {
7133
+ return false; // codex only writes it on a clean finish, and the other launchers never do.
7134
+ }
7135
+ if (!text) {
7136
+ return false;
7137
+ }
7138
+ // The worklog is read by people and by the next worker's brief; a whole transcript would bury both.
7139
+ const MAX = 4000;
7140
+ const body = text.length > MAX ? (text.slice(0, MAX) + '\n\n…truncated; full run: ' + options.logFile) : text;
7141
+ try {
7142
+ await buildAxios(context.baseUrl, context.session.token, 20000)
7143
+ .post('/cli/ticket', {
7144
+ action: 'progress',
7145
+ ticketId: ticket.ticketId || ticket.id,
7146
+ accountId: Number(ticket.accountId) || undefined,
7147
+ dataMode: context.dataMode,
7148
+ agentId,
7149
+ category: 'worker_summary',
7150
+ summary: 'Autonomous worker exited without recording a finding; this is its closing summary.',
7151
+ details: body,
7152
+ nextStep: options.nextStep || null
7153
+ });
7154
+ return true;
7155
+ } catch (err) {
7156
+ appendGlobalActivityLog('agent', 'worker.summary_post_failed', {
7157
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
7158
+ }, 'error');
7159
+ return false;
7160
+ }
7161
+ }
7162
+
7163
+ /** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
7164
+ async function releaseTicketClaim(context, agentId, ticket, options = {}) {
7165
+ try {
7166
+ const response = await buildAxios(context.baseUrl, context.session.token, 20000)
7167
+ .post('/cli/ticketClaim', {
7168
+ action: 'unclaim',
7169
+ agentId,
7170
+ ticketId: ticket.ticketId || ticket.id,
7171
+ accountId: Number(ticket.accountId),
7172
+ outcome: options.outcome || null,
7173
+ notes: options.notes || null,
7174
+ release: options.release === true,
7175
+ unroute: options.unroute === true
7176
+ }).then((r) => r.data);
7177
+ return response || null;
7178
+ } catch (err) {
7179
+ appendGlobalActivityLog('agent', 'worker.unclaim_failed', {
7180
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
7181
+ }, 'error');
7182
+ return null;
7183
+ }
7184
+ }
7185
+
7186
+ async function postAgentStatus(context, agentId, update) {
7187
+ try {
7188
+ await buildAxios(context.baseUrl, context.session.token, 10000)
7189
+ .post('/cli/agentHeartbeat', { agentId, ...update });
7190
+ } catch (_) {
7191
+ // Status is commentary. Losing a line of it must never interrupt a run.
7192
+ }
7193
+ }
7194
+
7195
+ /**
7196
+ * Launch one headless agent process for one ticket, and see it through to its exit.
7197
+ *
7198
+ * The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
7199
+ * the same thing; only the working directory is resolved here, because only this machine knows
7200
+ * where the account is checked out.
7201
+ */
7202
+ function spawnTicketWorker(context, ctx, ticket) {
7203
+ const { spawn } = require('child_process');
7204
+ const agentId = ctx.agentId;
7205
+ const ticketId = String(ticket.id || ticket.ticketId);
7206
+ const agentType = ctx.workerAgent;
7207
+ const launcher = AGENT_LAUNCHERS[agentType];
7208
+ const runId = ctx.runId + '-' + ticketId;
7209
+
7210
+ if (!launcher) {
7211
+ appendGlobalActivityLog('agent', 'worker.unsupported_agent', { agentId, ticketId, agentType }, 'error');
7212
+ return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
7213
+ }
7214
+
7215
+ const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
7216
+ // The brief is rendered server-side without the local path (the platform cannot know it), so the
7217
+ // resolved directory is appended here. When nothing resolved, the server-rendered brief already
7218
+ // tells the worker to find it rather than guess — do not paper over that with a default.
7219
+ // The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
7220
+ // learn it: `components status` would show it a lane it never chose, and anything it reports about "the
7221
+ // staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
7222
+ // is the whole class of defect the lane exists to remove.
7223
+ const workerWorkspace = 'ticket-' + ticketId;
7224
+ const brief = String(ticket.brief || '') + (workingDirectory
7225
+ ? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
7226
+ : '') +
7227
+ '\n\n## Your staging lane\n\n' +
7228
+ 'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
7229
+ 'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
7230
+ 'are isolated from anyone else working this repository, and it also means a person looking at the shared\n' +
7231
+ 'lane will not see your changes — so say which lane you staged into when you report what you verified.\n';
7232
+
7233
+ fs.mkdirSync(WORKER_LOG_DIR, { recursive: true });
7234
+ const logFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.log');
7235
+ const lastMessageFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.result.txt');
7236
+ // The sandbox and the brief MUST agree, and they used not to: `--mode investigate` set the launcher's
7237
+ // sandbox while the server-rendered brief, never told, said "edit and verify freely" — so a read-only
7238
+ // worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
7239
+ // it derived from the repository's edit lease, and that is the authority. The session's own --mode can
7240
+ // only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
7241
+ const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
7242
+ ? 'investigate'
7243
+ : 'edit';
7244
+ const built = launcher.build({
7245
+ cwd: workingDirectory || ctx.cwd,
7246
+ mode: effectiveMode,
7247
+ lastMessageFile: agentType === 'codex' ? lastMessageFile : null
7248
+ });
7249
+
7250
+ const attempt = recordWorkerAttempt(agentId, ticketId);
7251
+ const child = spawn(launcher.binary, built.args, {
7252
+ cwd: workingDirectory || ctx.cwd,
7253
+ stdio: ['pipe', 'pipe', 'pipe'],
7254
+ env: {
7255
+ ...process.env,
7256
+ // A worker must never be mistaken for its supervisor. Without this a `remits-cli agent status`
7257
+ // run from inside the worker would resolve ambiguously against the local store.
7258
+ REMITS_AGENT_ID: agentId,
7259
+ REMITS_SUPPORT_TICKET_ID: ticketId,
7260
+ REMITS_WORKER_RUN_ID: runId,
7261
+ // Give the worker its OWN staging lane.
7262
+ //
7263
+ // The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
7264
+ // protects the working tree, which two processes editing the same files would corrupt regardless of
7265
+ // staging. What the lease does NOT cover is everyone else pointed at the same directory: a developer
7266
+ // iterating in that repo, or a read-only investigating worker running a diagnostic. They all shared
7267
+ // one staging lane with the editing worker, and `components stage` uploads the whole repo and
7268
+ // REPLACES the lane — so a worker staging mid-edit made the human's next test run execute the
7269
+ // worker's half-finished code, and vice versa.
7270
+ //
7271
+ // Env var rather than a flag because the worker is an arbitrary coding agent running arbitrary
7272
+ // remits-cli commands; only the environment reaches all of them. An explicit --workspace on a
7273
+ // command still wins, and `.remits-cli/workspace` in the repo does not override it.
7274
+ REMITS_WORKSPACE: workerWorkspace
7275
+ }
7276
+ });
7277
+
7278
+ recordWorker(agentId, ticketId, {
7279
+ pid: child.pid,
7280
+ runId,
7281
+ // The lane this worker was launched into. An operator reading `agent workers` otherwise has to know
7282
+ // the naming convention to work out where its staged components went.
7283
+ workspace: workerWorkspace,
7284
+ attempt,
7285
+ agentType,
7286
+ // The sandbox the launcher actually applied. Kept beside the phase because for codex they are no
7287
+ // longer the same fact: an investigating worker still runs workspace-write (it needs the network to
7288
+ // report at all), and its boundary is the brief. An operator reading "investigating" must be able to
7289
+ // see that.
7290
+ sandbox: built.sandbox || null,
7291
+ startedAt: new Date().toISOString(),
7292
+ workingDirectory: workingDirectory || null,
7293
+ accountId: ticket.accountId || null,
7294
+ subject: ticket.subject || null,
7295
+ logFile
7296
+ });
7297
+ appendGlobalActivityLog('agent', 'worker.started', {
7298
+ agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
7299
+ // Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
7300
+ // on the record rather than inferable from a sandbox flag nobody logged.
7301
+ phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
7302
+ editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
7303
+ });
7304
+ if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
7305
+ appendGlobalActivityLog('agent', 'worker.read_only', {
7306
+ agentId, ticketId, message: ticket.editLeaseMessage
7307
+ });
7308
+ }
7309
+
7310
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' });
7311
+ let logBytes = 0;
7312
+ let lastStatusAtMs = 0;
7313
+ let buffered = '';
7314
+
7315
+ const consume = (chunk) => {
7316
+ const text = chunk.toString();
7317
+ if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
7318
+ logBytes += text.length;
7319
+ logStream.write(text);
7320
+ }
7321
+ buffered += text;
7322
+ const lines = buffered.split('\n');
7323
+ buffered = lines.pop();
7324
+ for (const line of lines) {
7325
+ if (!line.trim()) continue;
7326
+ const summary = summarizeWorkerEvent(line);
7327
+ if (!summary) continue;
7328
+ const nowMs = Date.now();
7329
+ if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
7330
+ lastStatusAtMs = nowMs;
7331
+ recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
7332
+ postAgentStatus(context, agentId, {
7333
+ state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
7334
+ });
7335
+ }
7336
+ };
7337
+
7338
+ child.stdout.on('data', consume);
7339
+ child.stderr.on('data', consume);
7340
+ child.stdin.on('error', () => { /* the worker exited before reading the brief */ });
7341
+ child.stdin.write(brief);
7342
+ child.stdin.end();
7343
+
7344
+ const done = new Promise((resolve) => {
7345
+ child.on('error', (err) => {
7346
+ appendGlobalActivityLog('agent', 'worker.spawn_failed',
7347
+ { agentId, ticketId, agentType, error: describeError(err) }, 'error');
7348
+ resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
7349
+ });
7350
+ child.on('close', async (code, signal) => {
7351
+ try { logStream.end(); } catch (_) { /* nothing to do */ }
7352
+ const ok = code === 0 && !signal;
7353
+ appendGlobalActivityLog('agent', 'worker.finished',
7354
+ { agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
7355
+
7356
+ // The exit code is a weak signal: a worker can exit non-zero having resolved the ticket, and
7357
+ // zero having done nothing. So the TICKET decides — unclaim always, and only give up (release
7358
+ // it back to the queue) once it has genuinely failed enough times and is still not terminal.
7359
+ const giveUp = !ok && attempt >= AGENT_WORKER_MAX_ATTEMPTS;
7360
+ const result = await releaseTicketClaim(context, agentId, { ticketId, accountId: ticket.accountId }, {
7361
+ outcome: ok ? 'completed' : (signal ? 'killed' : 'failed'),
7362
+ release: giveUp,
7363
+ // A clean exit that left the ticket short of terminal is a DELIVERED judgement, not a failure —
7364
+ // the worker chose pending_review, or documented that it cannot be done. Keeping it routed here
7365
+ // would have the next poll start another worker that reaches the same conclusion, forever.
7366
+ unroute: ok,
7367
+ notes: giveUp
7368
+ ? ('Autonomous ' + agentType + ' worker failed ' + attempt + ' time(s); returning to the queue. Log: ' + logFile)
7369
+ : null
7370
+ });
7371
+
7372
+ const row = (result && result.ticket) || {};
7373
+
7374
+ // Did this run leave anything on the record? If not, salvage its closing summary before the ticket
7375
+ // goes back to the queue — otherwise the next worker starts from exactly where this one did.
7376
+ // Compared against the count we were HANDED at spawn, so a worker that reported properly (or that
7377
+ // asked a question, or completed) is never duplicated.
7378
+ const worklogBefore = Number(ticket.worklogCount || 0);
7379
+ const worklogAfter = Number(row.worklogCount != null ? row.worklogCount : worklogBefore);
7380
+ if (worklogAfter <= worklogBefore && !['resolved', 'closed'].includes(String(row.status || ''))) {
7381
+ await postWorkerSummary(context, agentId, { ticketId, accountId: ticket.accountId }, lastMessageFile, {
7382
+ logFile,
7383
+ nextStep: giveUp
7384
+ ? 'This worker failed ' + attempt + ' time(s) and the ticket was returned to the queue.'
7385
+ : null
7386
+ });
7387
+ }
7388
+
7389
+ const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
7390
+ if (terminal || giveUp) {
7391
+ clearWorkerAttempts(agentId, ticketId);
7392
+ }
7393
+ clearWorker(agentId, ticketId);
7394
+ resolve({ ticketId, ok, code, signal, terminal, gaveUp: giveUp, status: row.status || null, logFile });
7395
+ });
7396
+ });
7397
+
7398
+ return { pid: child.pid, done };
7399
+ }
7400
+
7401
+ /**
7402
+ * The one detached process behind a registered agent session: presence, and optionally work.
7403
+ *
7404
+ * <p>Presence and work-collection live in the SAME process on purpose. Both need to know how many
7405
+ * workers are running — presence publishes it as capacity, collection needs it to decide how many
7406
+ * tickets to take — and two processes maintaining that number separately would disagree the moment
7407
+ * one of them stalled, with the platform believing whichever wrote last.</p>
7408
+ *
7409
+ * <p>When serving, the work poll IS the heartbeat: `/cli/agentWork` refreshes presence, publishes
7410
+ * the live worker set, renews their claims, and returns newly claimed work, all in one request.</p>
7411
+ */
5820
7412
  async function agentHeartbeatDaemonCommand(flags) {
5821
7413
  const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
5822
7414
  const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
@@ -5829,11 +7421,36 @@ async function agentHeartbeatDaemonCommand(flags) {
5829
7421
  // Re-registration must reuse the anchor it was given, never re-resolve one.
5830
7422
  process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
5831
7423
 
7424
+ const serving = flagEnabled(flags.serve);
7425
+ const ctx = {
7426
+ agentId,
7427
+ cwd,
7428
+ // The daemon is detached: its own process tree says nothing about the session it serves, so the
7429
+ // worker kind must come from the flag it was started with, never from re-detection.
7430
+ workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
7431
+ mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
7432
+ maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
7433
+ runId: 'run-' + Date.now().toString(36)
7434
+ };
7435
+ const inFlight = new Map();
7436
+
5832
7437
  const stop = async (reason) => {
5833
- appendGlobalActivityLog('agent', 'heartbeat.stopped', { agentId, anchorPid, reason });
7438
+ appendGlobalActivityLog('agent', 'heartbeat.stopped',
7439
+ { agentId, anchorPid, reason, workers: inFlight.size });
7440
+ // Kill the workers first, then drop their claims. A worker left running after its supervisor is
7441
+ // gone is unsupervised autonomy on production tickets — and its claim would then expire under a
7442
+ // process that is still writing.
7443
+ for (const [ticketId, entry] of inFlight.entries()) {
7444
+ try { process.kill(entry.pid, 'SIGTERM'); } catch (_) { /* already gone */ }
7445
+ clearWorker(agentId, ticketId);
7446
+ }
5834
7447
  forgetLocalAgent(agentId);
5835
7448
  try {
5836
7449
  const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
7450
+ for (const [ticketId, entry] of inFlight.entries()) {
7451
+ await releaseTicketClaim(context, agentId,
7452
+ { ticketId, accountId: entry.accountId }, { outcome: 'supervisor_stopped' });
7453
+ }
5837
7454
  await buildAxios(context.baseUrl, context.session.token, 5000)
5838
7455
  .post('/cli/agentDeregister', { agentId });
5839
7456
  } catch (_) {
@@ -5845,21 +7462,108 @@ async function agentHeartbeatDaemonCommand(flags) {
5845
7462
  process.on('SIGTERM', () => { stop('signal'); });
5846
7463
  process.on('SIGINT', () => { stop('signal'); });
5847
7464
 
7465
+ // A previous supervisor for this agent may have been killed mid-run, leaving claims behind. They
7466
+ // would expire on their own, but that is up to 15 minutes during which this session cannot restart
7467
+ // the work it is still routed. Clear anything whose process is demonstrably gone.
7468
+ if (serving) {
7469
+ for (const stale of listWorkers(agentId)) {
7470
+ if (stale && stale.pid && processIsAlive(stale.pid)) {
7471
+ continue;
7472
+ }
7473
+ clearWorker(agentId, stale.ticketId);
7474
+ try {
7475
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
7476
+ await releaseTicketClaim(context, agentId, stale, { outcome: 'orphaned' });
7477
+ } catch (_) { /* the claim expires regardless */ }
7478
+ }
7479
+ }
7480
+
7481
+ let emptyPolls = 0;
5848
7482
  for (;;) {
5849
7483
  if (!processIsAlive(anchorPid)) {
5850
7484
  await stop('anchor_exited');
5851
7485
  return;
5852
7486
  }
7487
+ let response = null;
5853
7488
  try {
5854
7489
  const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
5855
- const response = await buildAxios(context.baseUrl, context.session.token, 10000)
5856
- .post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
7490
+ const api = buildAxios(context.baseUrl, context.session.token, 30000);
7491
+
7492
+ if (serving) {
7493
+ const activeTickets = [...inFlight.keys()];
7494
+ response = await api.post('/cli/agentWork', {
7495
+ agentId,
7496
+ claim: inFlight.size < ctx.maxConcurrent,
7497
+ runId: ctx.runId,
7498
+ activeTickets,
7499
+ maxConcurrent: ctx.maxConcurrent,
7500
+ // Declared so the phase the platform derives already accounts for an operator who asked for a
7501
+ // read-only agent. Without it the server would hand out an edit lease this session will never
7502
+ // use, blocking a repository nobody is editing.
7503
+ mode: ctx.mode,
7504
+ statuses: 'all'
7505
+ }).then((r) => r.data);
7506
+
7507
+ // The CLI and the platform are released separately, so a new supervisor can meet an older
7508
+ // platform. That platform ignores `claim` and answers with EVERY routed ticket — which this loop
7509
+ // would read as "start all of these", spawning past capacity with no claim protecting any of
7510
+ // them. `supervising` is the platform confirming it understood the request, so its absence means
7511
+ // stand down rather than guess.
7512
+ if (response && response.supervising !== true) {
7513
+ if (emptyPolls === 0) {
7514
+ appendGlobalActivityLog('agent', 'serve.platform_too_old', {
7515
+ agentId,
7516
+ message: 'This platform does not support supervised claiming, so no workers will be started. ' +
7517
+ 'Update the platform, or work tickets manually with: remits-cli agent work'
7518
+ }, 'error');
7519
+ }
7520
+ emptyPolls += 1;
7521
+ await new Promise((resolve) => setTimeout(resolve, AGENT_SERVE_IDLE_POLL_MS));
7522
+ continue;
7523
+ }
7524
+
7525
+ // A swept ticket was routed at nobody and this session took it itself. Logged distinctly from a
7526
+ // routed one so an operator reading the activity log can tell "somebody handed me this" from
7527
+ // "nobody had, and I picked it up".
7528
+ const sweptIds = new Set(((response && response.sweptTicketIds) || []).map((id) => String(id)));
7529
+ if (sweptIds.size) {
7530
+ appendGlobalActivityLog('agent', 'serve.swept_unrouted', {
7531
+ agentId,
7532
+ ticketIds: [...sweptIds],
7533
+ message: 'Claimed ' + sweptIds.size + ' unrouted ticket(s) nobody had been given'
7534
+ });
7535
+ }
7536
+
7537
+ for (const ticket of (response && response.tickets) || []) {
7538
+ const ticketId = String(ticket.id || ticket.ticketId || '');
7539
+ if (!ticketId || inFlight.has(ticketId)) {
7540
+ continue;
7541
+ }
7542
+ const started = spawnTicketWorker(context, ctx, ticket);
7543
+ inFlight.set(ticketId, { pid: started.pid, accountId: ticket.accountId });
7544
+ started.done.then(() => {
7545
+ inFlight.delete(ticketId);
7546
+ postAgentStatus(context, agentId, inFlight.size
7547
+ ? { state: 'working', ticketId: [...inFlight.keys()][0] }
7548
+ : { state: 'idle', ticketId: null, activity: null, step: null });
7549
+ });
7550
+ }
7551
+ emptyPolls = (response && response.count) ? 0 : emptyPolls + 1;
7552
+ } else {
7553
+ response = await api.post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
7554
+ }
7555
+
5857
7556
  if (response && response.registered === false) {
5858
7557
  // The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
5859
7558
  // than heartbeating into a void — the tab is still very much alive.
5860
7559
  appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
5861
7560
  await agentRegisterCommand(
5862
- { 'agent-id': agentId, 'base-url': context.baseUrl, json: 'true' },
7561
+ {
7562
+ 'agent-id': agentId,
7563
+ 'base-url': context.baseUrl,
7564
+ 'max-concurrent': String(ctx.maxConcurrent),
7565
+ json: 'true'
7566
+ },
5863
7567
  { spawnHeartbeat: false }
5864
7568
  ).catch((err) => {
5865
7569
  appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
@@ -5869,7 +7573,11 @@ async function agentHeartbeatDaemonCommand(flags) {
5869
7573
  } catch (err) {
5870
7574
  appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
5871
7575
  }
5872
- await new Promise((resolve) => setTimeout(resolve, AGENT_HEARTBEAT_INTERVAL_MS));
7576
+
7577
+ const interval = !serving
7578
+ ? AGENT_HEARTBEAT_INTERVAL_MS
7579
+ : (emptyPolls >= AGENT_SERVE_IDLE_AFTER_POLLS ? AGENT_SERVE_IDLE_POLL_MS : AGENT_SERVE_POLL_MS);
7580
+ await new Promise((resolve) => setTimeout(resolve, interval));
5873
7581
  }
5874
7582
  }
5875
7583
 
@@ -5972,8 +7680,8 @@ async function agentWorkCommand(flags) {
5972
7680
  }
5973
7681
  console.log('');
5974
7682
  console.log('Next: read it in full, then claim it.');
5975
- console.log(' remits-cli tool --name mcp_support_ticket --input \'{"action":"read","ticketId":"' +
5976
- (tickets[0].id || tickets[0].ticketId) + '"}\'');
7683
+ console.log(' remits-cli ticket read --ticket ' + (tickets[0].id || tickets[0].ticketId));
7684
+ console.log(' remits-cli ticket accept --ticket ' + (tickets[0].id || tickets[0].ticketId));
5977
7685
  console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
5978
7686
  ' --activity "reproducing the issue"');
5979
7687
  return;
@@ -5983,6 +7691,647 @@ async function agentWorkCommand(flags) {
5983
7691
  }
5984
7692
  }
5985
7693
 
7694
+ /**
7695
+ * The generic ticket lifecycle — read a ticket, and move it through its workflow.
7696
+ *
7697
+ * Deliberately NOT a wrapper around any account's ticket tool. `mcp_support_ticket` belongs to one
7698
+ * particular System Account: it is owned by an account a given caller may not reach, it scopes to
7699
+ * its own account, and a partner platform need not have it at all. This talks to the platform's own
7700
+ * record, so the same commands work on every Remits platform — which is what an autonomous worker
7701
+ * needs, since its brief has to be written before anyone knows which platform it will run on.
7702
+ */
7703
+ async function ticketCommand(flags, subcommand) {
7704
+ const action = String(subcommand || 'read').toLowerCase();
7705
+
7706
+ // `queue` and `create` are the two verbs with no ticket to act on, so they are dispatched before the
7707
+ // id requirement rather than being forced to invent one.
7708
+ if (action === 'queue' || action === 'list' || action === 'ls') {
7709
+ return ticketQueueCommand(flags);
7710
+ }
7711
+ if (action === 'create' || action === 'new' || action === 'raise') {
7712
+ return ticketCreateCommand(flags);
7713
+ }
7714
+ // Bulk reclaim scans a queue rather than acting on one id, so it is dispatched here too. With
7715
+ // --ticket it falls through to the single-ticket path below like any other verb.
7716
+ if (action === 'reclaim' && !flags.ticket && !flags['ticket-id']) {
7717
+ return ticketReclaimSweepCommand(flags);
7718
+ }
7719
+
7720
+ const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
7721
+ if (!ticketId) {
7722
+ printTicketHelp();
7723
+ throw new Error('A --ticket ID is required (or set REMITS_SUPPORT_TICKET_ID).');
7724
+ }
7725
+
7726
+ const local = listLocalAgents()[0] || null;
7727
+ const context = agentSessionFor(flags, local);
7728
+
7729
+ // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
7730
+ // so it is dispatched here rather than folded into the lifecycle switch.
7731
+ if (action === 'route' || action === 'unroute') {
7732
+ const agentId = action === 'unroute' ? null : String(flags.agent || flags['agent-id'] || '').trim();
7733
+ if (action === 'route' && !agentId) {
7734
+ throw new Error('route needs --agent <agentId>. See: remits-cli agent list');
7735
+ }
7736
+ const accountId = Number(flags['account-id']) || (await resolveTicketAccountId(context, ticketId));
7737
+ const result = await buildAxios(context.baseUrl, context.session.token, 20000)
7738
+ .post('/cli/ticketRoute', { ticketId, accountId, agentId, dataMode: context.dataMode })
7739
+ .then((r) => r.data)
7740
+ .catch((err) => (err && err.response && err.response.data) || null);
7741
+ if (!result || result.success === false) {
7742
+ console.error('Refused: ' + ((result && result.message) || 'routing failed'));
7743
+ process.exitCode = 1;
7744
+ return;
7745
+ }
7746
+ console.log(result.routed
7747
+ ? 'routed ticket ' + ticketId + ' to ' + (result.agentLabel || result.agentId)
7748
+ : 'cleared routing on ticket ' + ticketId);
7749
+ return;
7750
+ }
7751
+
7752
+ const payload = {
7753
+ action,
7754
+ ticketId,
7755
+ accountId: Number(flags['account-id']) || undefined,
7756
+ dataMode: context.dataMode,
7757
+ status: flags.status,
7758
+ resolution: flags.resolution,
7759
+ assignee: flags.assignee,
7760
+ notes: flags.notes,
7761
+ summary: flags.summary || flags.note,
7762
+ question: flags.question,
7763
+ context: flags.context,
7764
+ // Recipients are a list on the platform side (message.to / deliver.to). A single value still works,
7765
+ // so `--to a@x.test` and `--to a@x.test,b@x.test` both do what they look like they do.
7766
+ to: flags.to ? String(flags.to).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7767
+ message: flags.message || flags.answer,
7768
+ route: flags.route === 'false' ? false : undefined,
7769
+ details: flags.details,
7770
+ nextStep: flags['next-step'],
7771
+ category: flags.category,
7772
+ // Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
7773
+ // falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
7774
+ // The platform side has always supported clearing (an explicit blank nulls the field) — only the CLI
7775
+ // could not ask for it. `--planned-in` with no value now means "clear it".
7776
+ plannedIn: planningFlagValue(firstDefined(flags['planned-in'], flags.plannedIn)),
7777
+ boardStage: planningFlagValue(firstDefined(flags['board-stage'], flags.boardStage)),
7778
+ rank: planningFlagValue(flags.rank),
7779
+ size: planningFlagValue(flags.size),
7780
+ blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7781
+ workstream: planningFlagValue(flags.workstream),
7782
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7783
+ label: flags.label,
7784
+ url: flags.url,
7785
+ body: flags.body,
7786
+ role: flags.role,
7787
+ email: flags.email,
7788
+ key: flags.key,
7789
+ value: flags.value,
7790
+ // Conversation and outbox. `--message` is shared with `answer`/`note`, which is deliberate: it is
7791
+ // always "the text", and the ACTION decides who ends up reading it.
7792
+ channel: flags.channel,
7793
+ subject: flags.subject,
7794
+ visibility: flags.visibility,
7795
+ direction: flags.direction,
7796
+ template: flags.template,
7797
+ cc: flags.cc ? String(flags.cc).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7798
+ deliveryId: flags['delivery-id'] || flags.deliveryId || undefined,
7799
+ idempotencyKey: flags['idempotency-key'] || flags.idempotencyKey || undefined,
7800
+ providerMessageId: flags['provider-message-id'] || undefined,
7801
+ reason: flags.reason,
7802
+ // Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
7803
+ // human's email is not a thing that can hold one. Inside a worker these come from the environment
7804
+ // the supervisor set, so the commands need no flags.
7805
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
7806
+ runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
7807
+ agentLabel: flags['agent-label'] || undefined,
7808
+ // WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
7809
+ // process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
7810
+ // (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
7811
+ ...runContextPayload(flags)
7812
+ };
7813
+
7814
+ let response;
7815
+ try {
7816
+ response = await buildAxios(context.baseUrl, context.session.token, 30000)
7817
+ .post('/cli/ticket', payload).then((r) => r.data);
7818
+ } catch (err) {
7819
+ // A lifecycle guard answers with 409 and a sentence saying what is wrong ("accept it first",
7820
+ // "already assigned to someone else"). That sentence IS the correction a caller needs, so it must
7821
+ // reach them intact rather than as an HTTP status.
7822
+ const body = err && err.response && err.response.data;
7823
+ if (body && body.message) {
7824
+ console.error('Refused: ' + body.message);
7825
+ process.exitCode = 1;
7826
+ return;
7827
+ }
7828
+ throw err;
7829
+ }
7830
+
7831
+ if (action === 'where' || action === 'context') {
7832
+ // The one run-context block, rendered by the platform so `ticket where`, `agent map`, the MCP tool and
7833
+ // the operator UI cannot paraphrase the same repository four different ways.
7834
+ const lines = (response && response.runContext && response.runContext.lines) || [];
7835
+ if (!lines.length) console.log('No run context for ticket ' + ticketId + '.');
7836
+ else lines.forEach((line) => console.log(' ' + line));
7837
+ return;
7838
+ }
7839
+
7840
+ if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
7841
+ console.log(JSON.stringify(response, null, 2));
7842
+ return;
7843
+ }
7844
+
7845
+ if (action === 'force-unlease' || action === 'force_unlease') {
7846
+ const previous = (response && response.previous) || null;
7847
+ console.log(response.released
7848
+ ? 'Edit lease on repository account ' + (response.repoAccountId || '?') + ' force-released' +
7849
+ (previous ? ' (was held by ' + (previous.agentLabel || previous.agentId) + ')' : '') + '.'
7850
+ : 'Nothing was holding the edit lease on this repository.');
7851
+ console.log(' A handoff worklog entry was recorded on ticket ' + ticketId + '.');
7852
+ return;
7853
+ }
7854
+
7855
+ if (action === 'lease' || action === 'unlease') {
7856
+ const lease = (response && response.editLease) || null;
7857
+ if (action === 'unlease') {
7858
+ console.log(response.released
7859
+ ? 'Edit lease released. Another agent can now edit this repository.'
7860
+ : 'You did not hold the edit lease on this repository; nothing to release.');
7861
+ return;
7862
+ }
7863
+ if (response.success) {
7864
+ console.log('Edit lease acquired — you may edit, stage and verify this repository.');
7865
+ console.log('Release it as soon as you are done: remits-cli ticket unlease');
7866
+ return;
7867
+ }
7868
+ // Not an error. The run continues read-only, and the message says who has it, WHERE they are working,
7869
+ // and what to do instead of waiting.
7870
+ console.log('Edit lease NOT acquired — this run stays read-only.');
7871
+ if (response.message) console.log(response.message);
7872
+ else if (lease) console.log('Held by ' + (lease.agentLabel || lease.agentId) + '.');
7873
+ const holderAt = describeRunLocation(lease);
7874
+ if (holderAt) console.log('Holder is working ' + holderAt);
7875
+ process.exitCode = 0;
7876
+ return;
7877
+ }
7878
+
7879
+ const ticket = (response && response.ticket) || {};
7880
+ console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
7881
+ (ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
7882
+ // Say what was actually written. Without this, `message`, `note`, `deliver` and `participant` all
7883
+ // printed the same status line, so the only feedback distinguishing "told the customer" from "left an
7884
+ // internal note" was the word the caller had just typed.
7885
+ if (action === 'message') {
7886
+ console.log(' public message added (' + (ticket.messageCount || '?') + ' on the ticket) — visible to the requester.');
7887
+ console.log(' This did NOT send anything. To have it delivered: remits-cli ticket deliver --channel email --to <who>');
7888
+ }
7889
+ if (action === 'note') console.log(' internal note added — the requester does not see it.');
7890
+ if (action === 'participant') console.log(' participants: ' + (ticket.participantCount || '?'));
7891
+ if (action === 'deliver') {
7892
+ console.log(' delivery queued (' + (ticket.deliveryCount || '?') + ' on the ticket). An account-owned rule or');
7893
+ console.log(' scheduled Action sends it. Check with: remits-cli ticket deliveries --ticket ' + ticketId);
7894
+ }
7895
+ if (action === 'planning' || action === 'plan') {
7896
+ const planning = [
7897
+ ticket.workstream ? 'workstream=' + ticket.workstream : '',
7898
+ ticket.plannedIn ? 'plannedIn=' + ticket.plannedIn : '',
7899
+ ticket.boardStage ? 'boardStage=' + ticket.boardStage : '',
7900
+ ticket.rank !== undefined && ticket.rank !== null && ticket.rank !== '' ? 'rank=' + ticket.rank : '',
7901
+ ticket.size ? 'size=' + ticket.size : '',
7902
+ ticket.blockedBy ? 'blockedBy=' + ticket.blockedBy : ''
7903
+ ].filter(Boolean).join(', ');
7904
+ console.log(' planning: ' + (planning || 'cleared'));
7905
+ }
7906
+ if (ticket.awaitingResponseSince) {
7907
+ console.log(' waiting on an answer since ' + ticket.awaitingResponseSince);
7908
+ if (ticket.awaitingResponseQuestion) {
7909
+ console.log(' question: ' + String(ticket.awaitingResponseQuestion).split('\n')[0].slice(0, 160));
7910
+ }
7911
+ console.log(' answer it with: remits-cli ticket reply --ticket ' + ticketId + ' --message "..."');
7912
+ }
7913
+ }
7914
+
7915
+ /**
7916
+ * The ticket queue — what else is open, and what it looks like as a whole.
7917
+ *
7918
+ * <p><b>Why this had to exist.</b> The worker brief is emphatic that `remits-cli ticket` is the surface to
7919
+ * use, and that an account's own ticket tooling must not be reached for: it belongs to one account, may not
7920
+ * exist on this platform, and refuses tickets other accounts own. That advice was right and the surface was
7921
+ * incomplete — it could act on ONE ticket whose id it was handed, and could not see anything else. So a
7922
+ * worker could not tell that the ticket it was given is one of eleven instances of the same fix, could not
7923
+ * check whether the defect it just found is already filed, and could not triage at all. The server side
7924
+ * already existed in full (`/cli/tickets`, the same `queue(...)` the operator UI reads); its only consumer
7925
+ * was the local dashboard.</p>
7926
+ */
7927
+ async function ticketQueueCommand(flags) {
7928
+ const local = listLocalAgents()[0] || null;
7929
+ const context = agentSessionFor(flags, local);
7930
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
7931
+ if (!Number.isFinite(accountId) || accountId <= 0) {
7932
+ throw new Error('ticket queue needs --account-id (or run it from an account repo).');
7933
+ }
7934
+
7935
+ const csv = (value) => (value ? String(value).split(',').map((v) => v.trim()).filter(Boolean) : undefined);
7936
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
7937
+ .post('/cli/tickets', {
7938
+ accountId,
7939
+ dataMode: context.dataMode,
7940
+ statuses: csv(flags.status || flags.statuses),
7941
+ priorities: csv(flags.priority || flags.priorities),
7942
+ types: csv(flags.type || flags.types),
7943
+ sources: csv(flags.source || flags.sources),
7944
+ workstream: flags.workstream,
7945
+ assignedTo: flags['assigned-to'] || flags.assignedTo,
7946
+ routedAgentId: flags['routed-to'] || flags.routedAgentId,
7947
+ unrouted: flagEnabled(flags.unrouted) || undefined,
7948
+ unassigned: flagEnabled(flags.unassigned) || undefined,
7949
+ // "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
7950
+ // pending_review, which also means "done, review me".
7951
+ awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
7952
+ plannedIn: flags['planned-in'] || flags.plannedIn,
7953
+ boardStage: flags['board-stage'] || flags.boardStage,
7954
+ blockedBy: flags['blocked-by'] || flags.blockedBy,
7955
+ search: flags.search || flags.q,
7956
+ sortBy: flags['sort-by'] || flags.sortBy,
7957
+ sortDirection: flags['sort-direction'] || flags.sortDirection,
7958
+ includeChildren: flags['include-children'] === 'false' ? false : undefined,
7959
+ limit: parsePositiveInt(flags.limit, 25)
7960
+ }).then((r) => r.data);
7961
+
7962
+ if (flagEnabled(flags.json)) {
7963
+ console.log(JSON.stringify(response, null, 2));
7964
+ return;
7965
+ }
7966
+
7967
+ const tickets = (response && response.tickets) || [];
7968
+ const counts = (response && response.counts) || {};
7969
+ if (!tickets.length) {
7970
+ console.log('No tickets match. (' + (response && response.dataMode) + ' lane, account ' + accountId + ')');
7971
+ return;
7972
+ }
7973
+ console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
7974
+ ' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
7975
+ ', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
7976
+ ' [' + (response.dataMode || '?') + ' lane]');
7977
+ console.log('');
7978
+ // Board position is printed only when some ticket in this queue actually has one. An always-on column
7979
+ // would be blank for every account that does not run a board, and a blank column reads as missing data.
7980
+ const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
7981
+ for (const t of tickets) {
7982
+ // The repo account, not just the owning account: it is what the edit lease excludes on, so it is
7983
+ // what tells a reader which of these can be worked at the same time.
7984
+ const repo = t.implementationAccountId || t.accountId;
7985
+ const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
7986
+ console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
7987
+ String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
7988
+ 'repo ' + String(repo || '?').padEnd(6) +
7989
+ String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
7990
+ // A second line rather than more columns: these are the account's own labels, of unbounded length,
7991
+ // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
7992
+ const marks = [
7993
+ t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
7994
+ showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
7995
+ showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
7996
+ showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
7997
+ ].filter(Boolean);
7998
+ if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
7999
+ }
8000
+ if (response.totalCount > tickets.length) {
8001
+ console.log('');
8002
+ console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
8003
+ }
8004
+ }
8005
+
8006
+ /**
8007
+ * Find tickets an agent took and abandoned, and hand them back to the queue.
8008
+ *
8009
+ * <p><b>Dry by default.</b> This takes work away from a holder on a judgement about staleness, across a
8010
+ * whole account tree, in one command — so the default is to show what it WOULD do. `--apply` is the
8011
+ * operator saying yes. The platform re-checks every row against the document before acting, so a stale
8012
+ * listing cannot cause a wrong reclaim even if this list is minutes old.</p>
8013
+ */
8014
+ async function ticketReclaimSweepCommand(flags) {
8015
+ const local = listLocalAgents()[0] || null;
8016
+ const context = agentSessionFor(flags, local);
8017
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8018
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8019
+ throw new Error('ticket reclaim needs --account-id (or --ticket ID for a single ticket).');
8020
+ }
8021
+ const staleHours = parsePositiveInt(flags['stale-hours'] || flags.staleHours, 24);
8022
+ const apply = flagEnabled(flags.apply);
8023
+
8024
+ const queue = await buildAxios(context.baseUrl, context.session.token, 30000)
8025
+ .post('/cli/tickets', {
8026
+ accountId, dataMode: context.dataMode,
8027
+ statuses: ['accepted', 'in_progress'], limit: parsePositiveInt(flags.limit, 200)
8028
+ }).then((r) => r.data);
8029
+
8030
+ const cutoff = Date.now() - staleHours * 3600000;
8031
+ // A local pre-filter only, to keep the output short and avoid asking about obviously-live rows. The
8032
+ // platform owns the actual decision and will refuse anything that does not qualify.
8033
+ const candidates = ((queue && queue.tickets) || []).filter((t) => {
8034
+ const holder = t.assignedTo || t.routedAgentId;
8035
+ if (!holder || String(holder).includes('@')) return false;
8036
+ if (t.awaitingResponseSince) return false;
8037
+ const touched = Date.parse(t.updatedAt || t.claimedAt || t.assignedAt || '');
8038
+ return Number.isFinite(touched) && touched < cutoff;
8039
+ });
8040
+
8041
+ if (!candidates.length) {
8042
+ console.log('No abandoned tickets under account ' + accountId + ' (agent-held, untouched for ' +
8043
+ staleHours + 'h). ' + (((queue && queue.tickets) || []).length) + ' accepted/in_progress ticket(s) checked.');
8044
+ return;
8045
+ }
8046
+
8047
+ console.log((apply ? 'Reclaiming ' : 'Would reclaim ') + candidates.length + ' abandoned ticket(s) — ' +
8048
+ 'agent-held, no live session, untouched for ' + staleHours + 'h:');
8049
+ console.log('');
8050
+ let done = 0;
8051
+ for (const t of candidates) {
8052
+ const id = String(t.ticketId || t.id);
8053
+ const line = ' ' + id.padStart(6) + ' ' + String(t.status || '').padEnd(13) +
8054
+ String(t.assignedTo || t.routedAgentId || '').slice(0, 16).padEnd(18) +
8055
+ String(t.subject || '').slice(0, 52);
8056
+ if (!apply) {
8057
+ console.log(line);
8058
+ continue;
8059
+ }
8060
+ try {
8061
+ await buildAxios(context.baseUrl, context.session.token, 30000)
8062
+ .post('/cli/ticket', {
8063
+ action: 'reclaim', ticketId: id, accountId: Number(t.accountId) || undefined,
8064
+ dataMode: context.dataMode, staleHours, reason: flags.reason || undefined
8065
+ });
8066
+ done += 1;
8067
+ console.log(line + ' -> open');
8068
+ } catch (err) {
8069
+ const body = err && err.response && err.response.data;
8070
+ // A refusal is the platform re-checking against the document and disagreeing with this listing.
8071
+ // That is the guard working, so it is reported per ticket rather than failing the run.
8072
+ console.log(line + ' -> refused: ' + ((body && body.message) || describeError(err)).slice(0, 90));
8073
+ }
8074
+ }
8075
+ console.log('');
8076
+ console.log(apply
8077
+ ? (done + ' reclaimed and back in the queue; an idle supervisor will sweep them.')
8078
+ : 'Dry run. Re-run with --apply to reclaim these.');
8079
+ }
8080
+
8081
+ /**
8082
+ * Raise a ticket.
8083
+ *
8084
+ * <p>The verb a worker needs when it finds a SECOND problem. Without it the only options were to widen the
8085
+ * ticket it was given until the record no longer described one piece of work, or to drop the finding.</p>
8086
+ */
8087
+ async function ticketCreateCommand(flags) {
8088
+ const local = listLocalAgents()[0] || null;
8089
+ const context = agentSessionFor(flags, local);
8090
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8091
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8092
+ throw new Error('ticket create needs --account-id (or run it from an account repo).');
8093
+ }
8094
+ if (!flags.subject) {
8095
+ throw new Error('ticket create needs --subject "..." and --type defect|question|task|incident|enhancement.');
8096
+ }
8097
+
8098
+ let response;
8099
+ try {
8100
+ response = await buildAxios(context.baseUrl, context.session.token, 30000)
8101
+ .post('/cli/ticketCreate', {
8102
+ accountId,
8103
+ dataMode: context.dataMode,
8104
+ subject: flags.subject,
8105
+ description: flags.description || flags.body,
8106
+ type: flags.type || 'defect',
8107
+ priority: flags.priority,
8108
+ source: flags.source,
8109
+ workstream: flags.workstream,
8110
+ affectedComponent: flags['affected-component'] || flags.affectedComponent,
8111
+ implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
8112
+ // A deterministic key makes this get-or-create, which is what stops a swept fleet raising the
8113
+ // same follow-up once per worker.
8114
+ referenceId: flags['reference-id'] || flags.referenceId,
8115
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
8116
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
8117
+ }).then((r) => r.data);
8118
+ } catch (err) {
8119
+ const body = err && err.response && err.response.data;
8120
+ if (body && body.message) {
8121
+ console.error('Refused: ' + body.message);
8122
+ process.exitCode = 1;
8123
+ return;
8124
+ }
8125
+ throw err;
8126
+ }
8127
+
8128
+ if (flagEnabled(flags.json)) {
8129
+ console.log(JSON.stringify(response, null, 2));
8130
+ return;
8131
+ }
8132
+ const t = (response && response.ticket) || {};
8133
+ console.log('created ticket ' + response.ticketId + ' on account ' + response.accountId +
8134
+ ' [' + (t.status || 'open') + ']');
8135
+ console.log(' ' + (t.subject || flags.subject));
8136
+ }
8137
+
8138
+ /** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
8139
+ async function resolveTicketAccountId(context, ticketId) {
8140
+ const read = await buildAxios(context.baseUrl, context.session.token, 20000)
8141
+ .post('/cli/ticket', { action: 'read', ticketId, dataMode: context.dataMode })
8142
+ .then((r) => r.data)
8143
+ .catch(() => null);
8144
+ const accountId = Number(read && read.ticket && read.ticket.accountId);
8145
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8146
+ throw new Error('Could not resolve the owning account for ticket ' + ticketId + '. Pass --account-id.');
8147
+ }
8148
+ return accountId;
8149
+ }
8150
+
8151
+ function printTicketHelp() {
8152
+ console.log('Usage: remits-cli ticket <action> --ticket ID');
8153
+ console.log('');
8154
+ console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
8155
+ console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
8156
+ console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
8157
+ console.log(' on, so it also tells you which of these can be worked at the same time.');
8158
+ console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
8159
+ console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
8160
+ console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
8161
+ console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
8162
+ console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
8163
+ console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
8164
+ console.log(' rather than widening the ticket you were given. [--priority --description --tags');
8165
+ console.log(' --workstream --affected-component --implementation-account-id --reference-id]');
8166
+ console.log(' --reference-id makes it get-or-create, so a re-run does not duplicate it.');
8167
+ console.log('');
8168
+ console.log(' read The full ticket record, as JSON.');
8169
+ console.log(' where WHERE this work is happening: repository account,');
8170
+ console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
8171
+ console.log(' live claim, and your phase. Read it BEFORE you edit anything — with git worktrees and');
8172
+ console.log(' workspace lanes, two agents on one account can be in completely different trees.');
8173
+ console.log(' accept Claim it as yours before you start work.');
8174
+ console.log(' status --status in_progress|pending_review');
8175
+ console.log(' progress --summary "..." [--category investigation|fix|verification|...] [--next-step "..."]');
8176
+ console.log(' complete --resolution "what you found and did"');
8177
+ console.log(' ask --question "..." [--context "..."] [--to WHO]');
8178
+ console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
8179
+ console.log(' answer --message "..." [--route false] (alias: reply)');
8180
+ console.log(' Answer the open question a previous run asked. By default this re-routes the ticket,');
8181
+ console.log(' so a fresh worker resumes with your answer already in its brief. This is NOT how you');
8182
+ console.log(' talk to the requester — that is `message`. (`reply` means the conversational one in');
8183
+ console.log(' some account tools, so this surface teaches `answer` and keeps `reply` as an alias.)');
8184
+ console.log(' message --message "..." [--to a@x,b@y] [--subject "..."] [--channel email]');
8185
+ console.log(' A PUBLIC conversation entry the requester reads. Writing it does not send it:');
8186
+ console.log(' the record is vendor-agnostic and the account\'s own rule chooses the transport.');
8187
+ console.log(' deliver --channel email --to a@x [--template T] [--subject "..."] [--idempotency-key K]');
8188
+ console.log(' Ask for that entry to actually be SENT. It records an outbox request; an account-owned');
8189
+ console.log(' rule or scheduled Action delivers it and marks the result. Use it when a ticket is');
8190
+ console.log(' waiting on somebody who is not watching the queue.');
8191
+ console.log(' deliveries [--channel email] What is queued to go out, and what already went.');
8192
+ console.log(' delivered --delivery-id ID | delivery-failed --delivery-id ID --reason "..."');
8193
+ console.log(' release [--notes "..."] Hand it back to the queue.');
8194
+ console.log(' reclaim [--ticket ID | --account-id ID] [--stale-hours 24] [--apply]');
8195
+ console.log(' OPERATOR: take a ticket back from an agent that accepted it and vanished. Such a');
8196
+ console.log(' ticket is invisible to the sweep (it is assigned, so nothing offers it to anyone).');
8197
+ console.log(' Never touches a ticket owned by a person, holding a live claim, or awaiting a reply.');
8198
+ console.log(' Without --ticket it scans the account tree and is a DRY RUN until --apply.');
8199
+ console.log(' reopen | assign --assignee EMAIL');
8200
+ console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
8201
+ console.log(' unroute Clear its routing, e.g. when that session is gone.');
8202
+ console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
8203
+ console.log(' near-identical tickets visible as one cluster to whoever looks next.');
8204
+ console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
8205
+ console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
8206
+ console.log(' worklog summary, where it is neither typed nor retrievable.');
8207
+ console.log(' note --message "..." An INTERNAL note. The next worker and a reviewer');
8208
+ console.log(' see it; the requester does not. Use `message` for anything they should read.');
8209
+ console.log(' participant --email E [--role watcher|requester|agent]');
8210
+ console.log(' field --key K --value V Write the ORGANIZATION\'s own field on the ticket,');
8211
+ console.log(' outside the platform\'s bounded planning slots. Whatever your account models.');
8212
+ console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
8213
+ console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
8214
+ console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
8215
+ console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
8216
+ console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
8217
+ console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
8218
+ console.log(' lease | unlease Take or give back the right to EDIT this ticket\'s');
8219
+ console.log(' repository. One agent at a time per account; everyone else investigates read-only.');
8220
+ console.log(' Your run already holds it if the brief said so. Take it when you started read-only');
8221
+ console.log(' and have reached an actual edit. A refusal is not an error — it names who has it,');
8222
+ console.log(' and investigating is still useful work. complete/ask/release free it for you.');
8223
+ console.log('');
8224
+ console.log(' Works on every Remits platform: it reads and writes the platform ticket record, not');
8225
+ console.log(' any one account\'s ticket tooling. Inside a worker, --ticket defaults to the ticket');
8226
+ console.log(' that worker was launched for.');
8227
+ }
8228
+
8229
+ /**
8230
+ * The run-context fields every call that RECORDS a location sends: checkout, branch, staging workspace.
8231
+ *
8232
+ * One helper so the lease, the claim and the registration cannot describe this process three ways —
8233
+ * the same reason the platform builds the tuple in exactly one class (AgentRunContext).
8234
+ */
8235
+ function runContextPayload(flags = {}) {
8236
+ const cwd = process.cwd();
8237
+ return {
8238
+ workingDirectory: cwd,
8239
+ branch: currentBranch(cwd) || undefined,
8240
+ workspace: resolveWorkspace(cwd, flags) || undefined
8241
+ };
8242
+ }
8243
+
8244
+ /**
8245
+ * WHERE a worker is working — checkout, branch, staging workspace lane — on one line.
8246
+ *
8247
+ * The platform builds the same string server-side (AgentRunContext.describeLocation) for the surfaces it
8248
+ * renders; this is the terminal's copy for rows it prints itself. The workspace is always named, `shared`
8249
+ * when it is the default lane, because "no workspace shown" and "the shared lane" were the same output
8250
+ * and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
8251
+ * each other's staged components.
8252
+ */
8253
+ function describeRunLocation(record) {
8254
+ if (!record) return '';
8255
+ const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
8256
+ const branch = record.branch || record.claimedBranch;
8257
+ const workspace = record.workspace || record.claimedWorkspace;
8258
+ if (!dir && !branch && !workspace) return '';
8259
+ const parts = [];
8260
+ if (dir) parts.push(dir);
8261
+ if (branch) parts.push('branch ' + branch);
8262
+ parts.push('workspace ' + (workspace || 'shared'));
8263
+ return parts.join(' ');
8264
+ }
8265
+
8266
+ /**
8267
+ * The live work map: which repositories are being edited, by whom, and who else is around.
8268
+ *
8269
+ * The question every agent has to answer before it starts — "can I safely edit account 33 right now" —
8270
+ * and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
8271
+ * on their own and never together, so every caller invented its own join and they disagreed.
8272
+ */
8273
+ async function agentWorkMapCommand(flags) {
8274
+ const agentId = resolveAgentId(flags, { optional: true });
8275
+ const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
8276
+ const context = agentSessionFor(flags, local);
8277
+ const accountId = flags['account-id'] ? Number(flags['account-id']) : Number(context.accountId);
8278
+
8279
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
8280
+ .post('/cli/workMap', {
8281
+ agentId,
8282
+ accountId,
8283
+ accountIds: flags['account-ids'] ? String(flags['account-ids']).split(',').map((s) => Number(s.trim())) : undefined,
8284
+ dataMode: context.dataMode,
8285
+ all: flagEnabled(flags.all)
8286
+ }).then((r) => r.data);
8287
+
8288
+ if (flagEnabled(flags.json)) {
8289
+ console.log(JSON.stringify(response, null, 2));
8290
+ return;
8291
+ }
8292
+
8293
+ const repositories = (response && response.repositories) || [];
8294
+ if (!repositories.length) {
8295
+ console.log('No repositories in scope for ' + context.baseUrl + ' (' + context.dataMode + ' lane).');
8296
+ return;
8297
+ }
8298
+ console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
8299
+ console.log('');
8300
+ for (const repo of repositories) {
8301
+ const lease = repo.editLease;
8302
+ console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
8303
+ (repo.accountType ? ' ' + repo.accountType : ''));
8304
+ if (lease) {
8305
+ const who = lease.agentLabel || lease.agentId;
8306
+ console.log(' EDIT LEASE ' + who +
8307
+ (lease.ticketId ? ' ticket ' + lease.ticketId : '') +
8308
+ (lease.heldForSeconds != null ? ' held ' + lease.heldForSeconds + 's' : '') +
8309
+ (lease.expiresInSeconds != null ? ' expires in ' + lease.expiresInSeconds + 's' : '') +
8310
+ (repo.editable ? ' (yours)' : ''));
8311
+ // The holder's checkout, branch and staging lane. Without them a refused agent cannot tell a real
8312
+ // file conflict from a holder working in an entirely different worktree.
8313
+ const holderAt = describeRunLocation(lease);
8314
+ if (holderAt) console.log(' ' + holderAt);
8315
+ } else {
8316
+ console.log(' EDIT LEASE free');
8317
+ }
8318
+ for (const agent of (repo.agents || [])) {
8319
+ // No phase means the session is present but holding nothing. Saying "investigating" there would
8320
+ // report work that is not happening.
8321
+ const phase = agent.phase ? agent.phase.padEnd(13) : 'available ';
8322
+ console.log(' ' + phase + ' ' +
8323
+ (agent.label || agent.agentId) + ' ' + (agent.state || '?') +
8324
+ (agent.activeTickets && agent.activeTickets.length ? ' tickets ' + agent.activeTickets.join(',') : '') +
8325
+ (agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
8326
+ const at = describeRunLocation(agent);
8327
+ if (at) console.log(' ' + at);
8328
+ if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
8329
+ }
8330
+ console.log('');
8331
+ }
8332
+ console.log('Only the lease holder may edit or stage a repository. Everyone else investigates read-only.');
8333
+ }
8334
+
5986
8335
  async function agentListCommand(flags) {
5987
8336
  const cwd = process.cwd();
5988
8337
  const mine = listLocalAgents()[0] || null;
@@ -6018,7 +8367,8 @@ async function agentListCommand(flags) {
6018
8367
  console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
6019
8368
  return;
6020
8369
  }
6021
- console.log(agents.length + ' agent(s) available across ' + scopeLabel + ':');
8370
+ console.log(agents.length + ' agent(s) available across ' + scopeLabel +
8371
+ ' in the ' + context.dataMode + ' lane:');
6022
8372
  for (const agent of agents) {
6023
8373
  console.log('');
6024
8374
  console.log(' ' + agent.agentId);
@@ -6026,9 +8376,117 @@ async function agentListCommand(flags) {
6026
8376
  console.log(' state ' + (agent.state || 'idle') +
6027
8377
  (agent.ticketId ? ' ticket ' + agent.ticketId : '') +
6028
8378
  ' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
8379
+ // The agent's OWN lane, not the query's. --all skips the lane filter for diagnostics, and without
8380
+ // this a test-lane session and a production one render identically in that listing. An agent at
8381
+ // capacity is likewise not routable, which is the other reason an idle-looking agent gets nothing.
8382
+ console.log(' lane ' + (agent.dataMode || 'prod') +
8383
+ ' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
8384
+ (agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
6029
8385
  if (agent.activity) console.log(' doing ' + agent.activity);
6030
- console.log(' where ' + (agent.host || '?') + ':' + (agent.cwd || '?'));
8386
+ // Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
8387
+ // `cwd`: a record written by an older platform, or still in Redis from before this field was
8388
+ // canonicalized, must not render as an unknown location.
8389
+ console.log(' where ' + (agent.host || '?') + ':' +
8390
+ (describeRunLocation(agent) || agent.workingDirectory || agent.cwd || '?'));
8391
+ }
8392
+ }
8393
+
8394
+ /**
8395
+ * Inspect and correct the local account-repository index — which checkout a ticket for an account is
8396
+ * worked in.
8397
+ *
8398
+ * <p>This index is the one fact the platform cannot supply: it knows the ticket and the account, and
8399
+ * only this machine knows where that account is checked out. It is built by scanning for
8400
+ * `account-info.json`, which is also present in every unpacked zip export, so it can and does acquire
8401
+ * entries that are not checkouts at all. There was no way to see it, and no way to correct it.</p>
8402
+ */
8403
+ async function agentReposCommand(flags) {
8404
+ const accountId = Number(flags['account-id'] || flags.accountId);
8405
+ const directory = flags.directory || flags.dir;
8406
+
8407
+ if (Number.isFinite(accountId) && accountId > 0 && directory) {
8408
+ const resolved = path.resolve(String(directory));
8409
+ if (!fs.existsSync(resolved)) {
8410
+ throw new Error('No such directory: ' + resolved);
8411
+ }
8412
+ if (!isAccountCheckout(resolved)) {
8413
+ throw new Error(resolved + ' is not a git checkout. A ticket worker edits, stages and reports ' +
8414
+ 'against this directory, so an unpacked export would silently lose the work.');
8415
+ }
8416
+ const info = loadAccountInfo(resolved);
8417
+ const declared = info ? Number(accountIdFromAccountInfo(info)) : null;
8418
+ if (declared && declared !== accountId) {
8419
+ throw new Error(resolved + ' declares account ' + declared + ', not ' + accountId +
8420
+ '. Point account ' + declared + ' at it instead, or pass the directory that belongs to ' + accountId + '.');
8421
+ }
8422
+ const index = readAccountRepoIndex();
8423
+ const previous = index[String(accountId)];
8424
+ index[String(accountId)] = {
8425
+ ...(previous || {}),
8426
+ ...(info ? buildAccountRepoEntryFromInfo(info, resolved, 'manual') : {}),
8427
+ accountId,
8428
+ directory: resolved,
8429
+ source: 'manual',
8430
+ // The operator has just answered the question, so the rival is no longer ambiguity.
8431
+ ambiguousWith: [],
8432
+ updatedAt: new Date().toISOString()
8433
+ };
8434
+ writeAccountRepoIndex(index);
8435
+ console.log('account ' + accountId + ' -> ' + resolved);
8436
+ if (previous && previous.directory && previous.directory !== resolved) {
8437
+ console.log(' (was ' + previous.directory + ')');
8438
+ }
8439
+ return;
8440
+ }
8441
+
8442
+ if (Number.isFinite(accountId) && accountId > 0 && flagEnabled(flags.forget)) {
8443
+ const index = readAccountRepoIndex();
8444
+ const previous = index[String(accountId)];
8445
+ delete index[String(accountId)];
8446
+ writeAccountRepoIndex(index);
8447
+ console.log(previous
8448
+ ? 'account ' + accountId + ' removed (was ' + previous.directory + '). Its tickets will now park ' +
8449
+ 'with an explanation rather than being worked in that directory.'
8450
+ : 'account ' + accountId + ' was not in the index.');
8451
+ return;
8452
+ }
8453
+
8454
+ if (flagEnabled(flags.rescan)) {
8455
+ try { discoverAccountRepos(); } catch (err) { console.error('scan failed: ' + describeError(err)); }
8456
+ }
8457
+
8458
+ const entries = listAccountRepoEntries(readAccountRepoIndex())
8459
+ .sort((a, b) => Number(a.accountId) - Number(b.accountId));
8460
+ if (flagEnabled(flags.json)) {
8461
+ console.log(JSON.stringify({ repos: entries, problems: accountRepoIndexProblems() }, null, 2));
8462
+ return;
8463
+ }
8464
+
8465
+ console.log('Account repositories on this machine (' + entries.length + '):');
8466
+ console.log('');
8467
+ for (const entry of entries) {
8468
+ const ok = isAccountCheckout(entry.directory);
8469
+ const missing = !fs.existsSync(entry.directory);
8470
+ const state = missing ? 'MISSING ' : (ok ? 'ok ' : 'NOT-GIT ');
8471
+ console.log(' ' + state + String(entry.accountId).padStart(5) + ' ' +
8472
+ String(entry.name || '').slice(0, 26).padEnd(26) + ' ' + entry.directory);
8473
+ for (const rival of entry.ambiguousWith || []) {
8474
+ console.log(' also claims this account: ' + rival);
8475
+ }
8476
+ }
8477
+
8478
+ const problems = accountRepoIndexProblems();
8479
+ if (problems.length) {
8480
+ console.log('');
8481
+ console.log(problems.length + ' issue(s):');
8482
+ for (const problem of problems) {
8483
+ console.log(' [' + problem.severity + '] ' + problem.message);
8484
+ }
6031
8485
  }
8486
+ console.log('');
8487
+ console.log(' Set one: remits-cli agent repos --account-id <id> --directory <path>');
8488
+ console.log(' Remove one: remits-cli agent repos --account-id <id> --forget');
8489
+ console.log(' Rescan: remits-cli agent repos --rescan');
6032
8490
  }
6033
8491
 
6034
8492
  async function agentReleaseCommand(flags) {
@@ -6038,19 +8496,40 @@ async function agentReleaseCommand(flags) {
6038
8496
  return;
6039
8497
  }
6040
8498
  const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
8499
+
8500
+ // Stop the supervisor BEFORE deregistering. The other order is a race the supervisor wins: it sees
8501
+ // registered:false, concludes its record expired, and re-registers the session we just retired.
8502
+ // Its own SIGTERM handler kills any workers and drops their claims, so this is a clean stop.
8503
+ let workersStopped = 0;
8504
+ const daemonPid = local.daemonPid && processIsAlive(local.daemonPid)
8505
+ ? local.daemonPid
8506
+ : findAgentDaemonPid(agentId);
8507
+ if (daemonPid) {
8508
+ workersStopped = listWorkers(agentId).length;
8509
+ try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
8510
+ }
8511
+
6041
8512
  const context = agentSessionFor(flags, local);
6042
8513
  await buildAxios(context.baseUrl, context.session.token, 10000)
6043
8514
  .post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
6044
8515
  forgetLocalAgent(agentId);
6045
8516
  console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
8517
+ if (workersStopped) {
8518
+ console.log('Stopped ' + workersStopped + ' running ticket worker(s); their claims were released so');
8519
+ console.log('another agent can pick the work up.');
8520
+ }
6046
8521
  }
6047
8522
 
6048
8523
  async function agentCommand(flags, subcommand) {
6049
8524
  switch (subcommand) {
6050
8525
  case 'register': return agentRegisterCommand(flags);
8526
+ case 'serve': case 'start': return agentServeCommand(flags);
8527
+ case 'workers': return agentWorkersCommand(flags);
6051
8528
  case 'status': return agentStatusCommand(flags);
6052
8529
  case 'work': return agentWorkCommand(flags);
6053
8530
  case 'list': case 'ls': return agentListCommand(flags);
8531
+ case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
8532
+ case 'repos': case 'repo': return agentReposCommand(flags);
6054
8533
  case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
6055
8534
  case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
6056
8535
  default:
@@ -6062,8 +8541,23 @@ async function agentCommand(flags, subcommand) {
6062
8541
  function printAgentHelp() {
6063
8542
  console.log('Usage: remits-cli agent <subcommand>');
6064
8543
  console.log('');
8544
+ console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
8545
+ console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
8546
+ console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
8547
+ console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
8548
+ console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
8549
+ console.log(' Closing the tab stops the agent.');
8550
+ console.log('');
8551
+ console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
8552
+ console.log(' session is running (when you start it from inside one), else your');
8553
+ console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
8554
+ console.log(' --mode investigate runs workers read-only (no file edits).');
8555
+ console.log('');
8556
+ console.log(' workers [--json] What this session is running right now.');
8557
+ console.log('');
6065
8558
  console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
6066
- console.log(' Make THIS terminal session available to work support tickets. Runs from ANY');
8559
+ console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
8560
+ console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
6067
8561
  console.log(' directory — it registers for every account repo indexed on this machine that your');
6068
8562
  console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
6069
8563
  console.log(' production platform and the prod lane, because a support agent works real tickets.');
@@ -6078,6 +8572,15 @@ function printAgentHelp() {
6078
8572
  console.log(' Report what this session is doing, so operators can see it live.');
6079
8573
  console.log('');
6080
8574
  console.log(' list [--account-id ID] [--json] Who else is available for an account.');
8575
+ console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
8576
+ console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
8577
+ console.log(' investigates read-only. Check this before starting work that changes files.');
8578
+ console.log(' repos [--rescan] [--json] WHERE each account is checked out on this machine —');
8579
+ console.log(' the one fact the platform cannot supply, and what decides the directory a ticket');
8580
+ console.log(' worker is launched into. Flags anything that is not a git checkout (an unpacked');
8581
+ console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
8582
+ console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
8583
+ console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
6081
8584
  console.log(' release Deregister this session immediately.');
6082
8585
  console.log('');
6083
8586
  console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
@@ -6502,24 +9005,31 @@ function printComponentsHelp(subcommand) {
6502
9005
  return;
6503
9006
  }
6504
9007
  if (subcommand === 'stage' || subcommand === 'push') {
6505
- console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
9008
+ console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
6506
9009
  console.log('');
6507
9010
  console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
6508
9011
  console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
9012
+ console.log('');
9013
+ console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
9014
+ console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
9015
+ console.log(' (entries for untouched components are left alone rather than deleted).');
6509
9016
  return;
6510
9017
  }
6511
9018
  if (subcommand === 'status') {
6512
- console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
9019
+ console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
6513
9020
  console.log('');
6514
9021
  console.log('Shows staged entries and whether this checkout resolves/writes trunk or a variant branch.');
9022
+ console.log('Also lists every OTHER staging lane on this branch for this account/user, so you can see');
9023
+ console.log('whether another agent is staging alongside you.');
6515
9024
  return;
6516
9025
  }
6517
9026
  if (subcommand === 'clear') {
6518
- console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
9027
+ console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
6519
9028
  console.log('');
6520
9029
  console.log('Clears staged Redis entries without touching the database or git.');
6521
9030
  console.log('Use --component-type with --component-id when the id could exist in multiple component families.');
6522
9031
  console.log('Use --all for the temporary-experiment cleanup flow.');
9032
+ console.log('--all is scoped to YOUR workspace lane; it never clears another lane on the same branch.');
6523
9033
  return;
6524
9034
  }
6525
9035
  if (subcommand === 'commit') {
@@ -6530,6 +9040,9 @@ function printComponentsHelp(subcommand) {
6530
9040
  return;
6531
9041
  }
6532
9042
  console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
9043
+ console.log('');
9044
+ console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
9045
+ console.log('so several agents can iterate at once. See: remits-cli workspace --help');
6533
9046
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
6534
9047
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
6535
9048
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
@@ -6576,8 +9089,16 @@ function printToolHelp() {
6576
9089
  console.log('Usage: remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--branch BRANCH] [--input "{...}"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
6577
9090
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
6578
9091
  console.log('');
9092
+ console.log('Hierarchy scope (read/discovery tools) — you do NOT need the owning child account id:');
9093
+ console.log(' --scope self|children|hierarchy expand from the repo account when searching');
9094
+ console.log(' --target-account-id ID the exact account to act on (required by writes)');
9095
+ console.log(' --account-ids 1,2,3 an explicit bounded list of owner accounts');
9096
+ console.log(' --anchor-account-id ID disambiguate the path for a multi-parent account');
9097
+ console.log(' These are merged into --input; a value already in --input wins.');
9098
+ console.log('');
6579
9099
  console.log('Examples:');
6580
9100
  console.log(' remits-cli tool --name mcp_firestore_search --input-file query.json --data-mode prod');
9101
+ console.log(' remits-cli tool --name mcp_firestore_search --input \'{"collection":"statements","documentId":"1234"}\' --scope children');
6581
9102
  console.log(' remits-cli tool --name mcp_run_action --input \'{"accountId":49,"actionId":200,"executionMode":"async"}\' --data-mode prod');
6582
9103
  console.log('');
6583
9104
  console.log('For mcp_run_action and mcp_run_agent, prefer the tool\'s own executionMode:"async" and poll with that tool\'s controlAction:"status".');
@@ -6623,7 +9144,7 @@ async function main() {
6623
9144
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
6624
9145
  // Auto-start the background service if not already running.
6625
9146
  // Skip for lifecycle subcommands and help.
6626
- const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
9147
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent' || command === 'ticket';
6627
9148
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
6628
9149
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
6629
9150
  try {
@@ -6654,6 +9175,7 @@ async function main() {
6654
9175
  console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
6655
9176
  console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
6656
9177
  console.log(' remits-cli agent list [--account-id ID] # who else is available');
9178
+ console.log(' remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING what, where, right now');
6657
9179
  console.log(' remits-cli agent release # stop receiving tickets now');
6658
9180
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
6659
9181
  console.log(' remits-cli stop');
@@ -6663,11 +9185,12 @@ async function main() {
6663
9185
  console.log(' remits-cli data-mode [set test|prod]');
6664
9186
  console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
6665
9187
  console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
6666
- console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
9188
+ console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--target-account-id ID] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
6667
9189
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
6668
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
6669
- console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
6670
- console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
9190
+ console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
9191
+ console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
9192
+ console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
9193
+ console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
6671
9194
  console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
6672
9195
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
6673
9196
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
@@ -6688,6 +9211,34 @@ async function main() {
6688
9211
  process.exit(0);
6689
9212
  }
6690
9213
 
9214
+ if (wantsHelp && (command === 'workspace' || command === 'ws')) {
9215
+ console.log('Usage: remits-cli workspace [show | use <name> | use --auto | clear]');
9216
+ console.log('');
9217
+ console.log('A WORKSPACE is an optional second isolation axis for staging, alongside the git branch.');
9218
+ console.log('Staging is keyed by (account, cli user, branch, workspace) — and account and user are fixed');
9219
+ console.log('for a given repo, so without a workspace the branch is the ONLY axis. That is why two coding');
9220
+ console.log('agents in two worktrees of the same branch overwrite each other: `components stage` uploads');
9221
+ console.log('the whole repo and replaces the lane wholesale.');
9222
+ console.log('');
9223
+ console.log('A workspace isolates staging WITHOUT changing anything else:');
9224
+ console.log(' - a commit still targets the same branch and the same owner account');
9225
+ console.log(' - the run still resolves whatever committed ComponentVariant branch the account subscribes to');
9226
+ console.log(' - only which staged entries shadow them changes');
9227
+ console.log('');
9228
+ console.log('Typical setup, one worktree per agent, all on the same branch:');
9229
+ console.log(' git worktree add ../ms-agent-a forked');
9230
+ console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
9231
+ console.log(' remits-cli components stage # isolated lane; nobody else sees it');
9232
+ console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
9233
+ console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
9234
+ console.log('');
9235
+ console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
9236
+ console.log('Use --no-workspace on a single command to target the shared lane without clearing the file.');
9237
+ console.log('');
9238
+ console.log('`.remits-cli/workspace` is per-checkout and gitignored, so a worktree keeps its own lane.');
9239
+ process.exit(0);
9240
+ }
9241
+
6691
9242
  if (wantsHelp && command === 'test') {
6692
9243
  printTestHelp();
6693
9244
  process.exit(0);
@@ -6813,11 +9364,25 @@ async function main() {
6813
9364
  return;
6814
9365
  }
6815
9366
 
9367
+ if (command === 'ticket') {
9368
+ if (wantsHelp || !subcommand) {
9369
+ printTicketHelp();
9370
+ process.exit(0);
9371
+ }
9372
+ await ticketCommand(args, subcommand);
9373
+ return;
9374
+ }
9375
+
6816
9376
  if (command === 'install') {
6817
9377
  await installSkillsCommand(args);
6818
9378
  return;
6819
9379
  }
6820
9380
 
9381
+ if (command === 'workspace' || command === 'ws') {
9382
+ await workspaceCommand(args);
9383
+ return;
9384
+ }
9385
+
6821
9386
  if (command === 'test' && subcommand === 'run') {
6822
9387
  await testCommand(args);
6823
9388
  return;