@remits/remits-cli 0.1.110 → 0.1.112

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);
@@ -1615,6 +1716,25 @@ function parsePositiveInt(value, fallback) {
1615
1716
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
1616
1717
  }
1617
1718
 
1719
+ /**
1720
+ * How long to give a full stage, from how much it is actually uploading.
1721
+ *
1722
+ * A fixed 60s was fine for a small repo and wrong for a real one: the request completes server-side and
1723
+ * the client abandons it, which is indistinguishable at the terminal from a stage that never landed.
1724
+ * Roughly 60s of headroom per megabyte on top of a 60s floor, capped so a pathological payload cannot
1725
+ * hang a session forever. `--stage-timeout-ms` overrides it.
1726
+ */
1727
+ function stageTimeoutMs(components, flags = {}) {
1728
+ const override = parsePositiveInt(flags['stage-timeout-ms'] || flags.stageTimeoutMs, 0);
1729
+ if (override) return override;
1730
+ let bytes = 0;
1731
+ for (const component of components || []) {
1732
+ try { bytes += Buffer.byteLength(JSON.stringify(component)); } catch (_) { /* unmeasurable is fine */ }
1733
+ }
1734
+ const megabytes = bytes / (1024 * 1024);
1735
+ return Math.min(600000, Math.max(60000, Math.round(60000 + (megabytes * 60000))));
1736
+ }
1737
+
1618
1738
  function buildAxios(baseUrl, token, timeoutMs = 60000) {
1619
1739
  const headers = token ? { Authorization: 'Bearer ' + token } : {};
1620
1740
  return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
@@ -2059,24 +2179,54 @@ async function pushComponentsCommand(flags) {
2059
2179
  const { session, accountId } = sessionContext;
2060
2180
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2061
2181
  const branchName = flags.branch || currentBranch(cwd);
2182
+ const workspace = resolveWorkspace(cwd, flags);
2062
2183
  const dataMode = resolveDataMode(flags, session);
2063
2184
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
2064
2185
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2065
- const components = collectComponents(cwd);
2066
2186
  const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2067
2187
 
2188
+ // --changed-only stages just the components this working tree edited, instead of re-uploading all of
2189
+ // them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
2190
+ // deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
2191
+ // components this partial stage did not mention.
2192
+ const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2193
+ let components = collectComponents(cwd);
2194
+ if (changedOnly) {
2195
+ if (changedFromWorkingTree === null) {
2196
+ throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
2197
+ }
2198
+ const wanted = new Set(changedFromWorkingTree.map((entry) =>
2199
+ entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2200
+ components = components.filter((component) => wanted.has(
2201
+ component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2202
+ if (!components.length) {
2203
+ console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
2204
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2205
+ return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
2206
+ }
2207
+ }
2208
+
2068
2209
  if (requestedMode === 'push') {
2069
2210
  console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
2070
2211
  }
2071
2212
 
2072
- const api = buildAxios(baseUrl, session.token);
2213
+ // A full stage uploads the WHOLE repo, and a large one is megabytes: Merchant Statements is 9.6 MB
2214
+ // across 106 components. Against a remote platform that routinely runs past 60s — and the failure was
2215
+ // the worst possible shape, because the server had already stored everything: the CLI reported
2216
+ // "timeout ... ECONNABORTED" over a stage that had SUCCEEDED, so the caller would either re-upload it
2217
+ // or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
2218
+ // the old default, and overridable.
2219
+ const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
2073
2220
  const response = await loggedPost(api, cwd, '/cli/components', {
2074
2221
  token: session.token,
2075
2222
  accountId,
2076
2223
  branchName,
2224
+ workspace,
2077
2225
  dataMode,
2078
2226
  mode,
2079
- replace: true,
2227
+ // A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
2228
+ // every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
2229
+ replace: !changedOnly,
2080
2230
  components
2081
2231
  }).then((r) => r.data);
2082
2232
  response.changedFromWorkingTree = changedFromWorkingTree || [];
@@ -2089,8 +2239,9 @@ async function pushComponentsCommand(flags) {
2089
2239
 
2090
2240
  printSessionResolutionWarning(sessionContext);
2091
2241
  printResolvedBaseUrl(baseUrl);
2242
+ printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
2092
2243
  console.log('Data mode:', response.dataMode || dataMode);
2093
- console.log('Mode:', response.mode || mode);
2244
+ console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
2094
2245
  printStageSummary(response, flags);
2095
2246
  }
2096
2247
 
@@ -2166,8 +2317,26 @@ function printStageSummary(response, flags) {
2166
2317
 
2167
2318
  function printStatusSummary(response, flags) {
2168
2319
  printBranchContext(response);
2320
+ printStagingLane(response.branchName, response.workspace, null);
2169
2321
  console.log('Staged count:', response.stagedCount || 0);
2170
2322
  printComponentTypeCounts(response.entries || []);
2323
+
2324
+ // Every lane staged on this branch, not just this one. An agent that believes it is working alone can
2325
+ // see here that it is not — which was impossible before workspaces existed, and is a large part of why
2326
+ // concurrent agents overwrote each other without anyone being able to observe it.
2327
+ const lanes = Array.isArray(response.workspaces) ? response.workspaces : [];
2328
+ if (lanes.length > 1 || (lanes.length === 1 && lanes[0].workspace !== (response.workspace || null))) {
2329
+ console.log('');
2330
+ console.log('Other staging lanes on branch "' + response.branchName + '" for this account/user:');
2331
+ lanes.forEach((lane) => {
2332
+ const isCurrent = (lane.workspace || null) === (response.workspace || null);
2333
+ const label = lane.workspace ? 'workspace ' + lane.workspace : 'shared default lane';
2334
+ const ttl = lane.ttlSeconds != null ? ' expires in ~' + Math.round(lane.ttlSeconds / 60) + 'm' : '';
2335
+ console.log(' ' + (isCurrent ? '* ' : ' ') + label + ' — ' + (lane.keyCount || 0) + ' staged key(s)' + ttl);
2336
+ });
2337
+ console.log(' (* = this command\'s lane)');
2338
+ }
2339
+
2171
2340
  printComponentCommandResponse('Components staging', response, flags);
2172
2341
  }
2173
2342
 
@@ -2210,6 +2379,64 @@ function printClearSummary(response, flags) {
2210
2379
  printComponentCommandResponse('Components clear', response, flags);
2211
2380
  }
2212
2381
 
2382
+ /**
2383
+ * `remits-cli workspace [show|use <name>|clear]`
2384
+ *
2385
+ * The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
2386
+ * checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
2387
+ * an agent its own isolated staging lane on the SAME branch.
2388
+ */
2389
+ async function workspaceCommand(flags) {
2390
+ const cwd = process.cwd();
2391
+ ensureLocalState(cwd);
2392
+ const sub = String((flags._ && flags._[1]) || 'show').toLowerCase();
2393
+
2394
+ if (sub === 'clear' || sub === 'unset' || sub === 'none') {
2395
+ writeWorkspaceFile(cwd, null);
2396
+ console.log('Workspace cleared. This checkout now stages into the shared default lane for its branch.');
2397
+ return;
2398
+ }
2399
+
2400
+ if (sub === 'use' || sub === 'set') {
2401
+ const auto = flagEnabled(flags.auto);
2402
+ const requested = auto ? path.basename(cwd) : (flags._ && flags._[2]) || flags.name || flags.workspace;
2403
+ if (!requested) {
2404
+ throw new Error('Usage: remits-cli workspace use <name> (or --auto to name it after this directory)');
2405
+ }
2406
+ const normalized = normalizeWorkspace(requested);
2407
+ if (!normalized) {
2408
+ throw new Error('Workspace name "' + requested + '" normalizes to nothing. Use letters, digits, dot, dash or underscore.');
2409
+ }
2410
+ writeWorkspaceFile(cwd, normalized);
2411
+ console.log('Workspace set to:', normalized);
2412
+ if (normalized !== String(requested).trim()) {
2413
+ // Say so rather than silently accepting a name that is not the lane. An agent that thinks it is on
2414
+ // "Agent A" and is actually on "agent-a" cannot reconcile its own output with a status listing.
2415
+ console.log(' (normalized from "' + String(requested).trim() + '")');
2416
+ }
2417
+ console.log('Stored in:', localStatePaths(cwd).workspaceFile, '(gitignored, per-checkout)');
2418
+ console.log('');
2419
+ console.log('Every stage / test run / token from this directory now uses this lane.');
2420
+ console.log('It does NOT change which branch a commit targets, or which committed variants run.');
2421
+ return;
2422
+ }
2423
+
2424
+ if (sub !== 'show' && sub !== 'status') {
2425
+ throw new Error('Unknown workspace subcommand "' + sub + '". Expected show, use, or clear.');
2426
+ }
2427
+
2428
+ const branchName = flags.branch || currentBranch(cwd);
2429
+ const workspace = resolveWorkspace(cwd, flags);
2430
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2431
+ console.log('');
2432
+ console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
2433
+ console.log('');
2434
+ console.log(' remits-cli workspace use <name> set this checkout\'s lane');
2435
+ console.log(' remits-cli workspace use --auto name it after this directory (' + normalizeWorkspace(path.basename(cwd)) + ')');
2436
+ console.log(' remits-cli workspace clear go back to the shared default lane');
2437
+ console.log(' remits-cli components status see every lane staged on this branch');
2438
+ }
2439
+
2213
2440
  async function statusComponentsCommand(flags) {
2214
2441
  const cwd = process.cwd();
2215
2442
  ensureLocalState(cwd);
@@ -2217,6 +2444,7 @@ async function statusComponentsCommand(flags) {
2217
2444
  const { session, accountId } = sessionContext;
2218
2445
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2219
2446
  const branchName = flags.branch || currentBranch(cwd);
2447
+ const workspace = resolveWorkspace(cwd, flags);
2220
2448
  const dataMode = resolveDataMode(flags, session);
2221
2449
  const api = buildAxios(baseUrl, session.token);
2222
2450
 
@@ -2224,6 +2452,7 @@ async function statusComponentsCommand(flags) {
2224
2452
  token: session.token,
2225
2453
  accountId,
2226
2454
  branchName,
2455
+ workspace,
2227
2456
  dataMode,
2228
2457
  mode: 'status',
2229
2458
  componentType: flags['component-type'] || flags.type,
@@ -2272,6 +2501,9 @@ async function clearComponentsCommand(flags) {
2272
2501
  const response = await loggedPost(api, cwd, '/cli/components', {
2273
2502
  token: session.token,
2274
2503
  accountId,
2504
+ // A clear NEVER reaches beyond this lane, so `--all` means "all of MY staged entries", not
2505
+ // "everything staged on this branch by anyone".
2506
+ workspace: resolveWorkspace(cwd, flags),
2275
2507
  branchName,
2276
2508
  dataMode,
2277
2509
  mode: 'clear',
@@ -2291,6 +2523,7 @@ async function clearComponentsCommand(flags) {
2291
2523
 
2292
2524
  printSessionResolutionWarning(sessionContext);
2293
2525
  printResolvedBaseUrl(baseUrl);
2526
+ printStagingLane(response.branchName || branchName, response.workspace, workspaceSource(cwd, flags));
2294
2527
  console.log('Data mode:', response.dataMode || dataMode);
2295
2528
  console.log('Mode:', response.mode || 'clear');
2296
2529
  printClearSummary(response, flags);
@@ -2304,6 +2537,7 @@ async function syncComponentsCommand(flags) {
2304
2537
  const { session, accountId } = sessionContext;
2305
2538
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
2306
2539
  const branchName = flags.branch || currentBranch(cwd);
2540
+ const workspace = resolveWorkspace(cwd, flags);
2307
2541
  const dataMode = resolveDataMode(flags, session);
2308
2542
  const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
2309
2543
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
@@ -2325,6 +2559,11 @@ async function syncComponentsCommand(flags) {
2325
2559
  token: session.token,
2326
2560
  accountId,
2327
2561
  branchName,
2562
+ // A successful sync clears the staging scope so stale staged aliases cannot shadow the freshly synced
2563
+ // DB rows. Sending the workspace keeps that clear scoped to THIS lane: without it, one agent's sync
2564
+ // would wipe every sibling agent's staged work on the same branch, which is the exact failure the
2565
+ // workspace lane exists to remove.
2566
+ workspace,
2328
2567
  dataMode,
2329
2568
  forceTombstones,
2330
2569
  dryRun,
@@ -2338,6 +2577,7 @@ async function syncComponentsCommand(flags) {
2338
2577
  token: session.token,
2339
2578
  accountId,
2340
2579
  branchName,
2580
+ workspace,
2341
2581
  dataMode,
2342
2582
  mode: 'status'
2343
2583
  }).then((r) => r.data);
@@ -3230,6 +3470,7 @@ async function testCommand(flags) {
3230
3470
  const { session, accountId } = sessionContext;
3231
3471
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3232
3472
  const branchName = flags.branch || currentBranch(cwd);
3473
+ const workspace = resolveWorkspace(cwd, flags);
3233
3474
  // Test runs must default to test dataMode even when the most-recent authenticated
3234
3475
  // session for the account is prod. Production test runs therefore require an explicit
3235
3476
  // --data-mode prod on the command line.
@@ -3285,6 +3526,7 @@ async function testCommand(flags) {
3285
3526
  asAccountId,
3286
3527
  variantBranch,
3287
3528
  branchName,
3529
+ workspace,
3288
3530
  dataMode,
3289
3531
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
3290
3532
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
@@ -3297,6 +3539,7 @@ async function testCommand(flags) {
3297
3539
 
3298
3540
  printSessionResolutionWarning(sessionContext);
3299
3541
  printResolvedBaseUrl(baseUrl);
3542
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
3300
3543
  console.log('Test run started:', start.taskId);
3301
3544
  runtimeState.currentTestTaskId = start.taskId;
3302
3545
 
@@ -3354,6 +3597,7 @@ async function tokenCommand(flags) {
3354
3597
  const { session, accountId } = sessionContext;
3355
3598
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3356
3599
  const branchName = flags.branch || currentBranch(cwd);
3600
+ const workspace = resolveWorkspace(cwd, flags);
3357
3601
  const dataMode = resolveDataMode(flags, session);
3358
3602
 
3359
3603
  const api = buildAxios(baseUrl, session.token);
@@ -3371,6 +3615,7 @@ async function tokenCommand(flags) {
3371
3615
  asAccountId: flags['as-account'] || flags['as-account-id'],
3372
3616
  variantBranch,
3373
3617
  branchName,
3618
+ workspace,
3374
3619
  dataMode,
3375
3620
  path: requestedPath
3376
3621
  }).then((r) => r.data);
@@ -3396,6 +3641,8 @@ async function tokenCommand(flags) {
3396
3641
  accountId: data.accountId,
3397
3642
  branchName: data.branchName,
3398
3643
  variantBranch: data.variantBranch,
3644
+ workspace: data.workspace !== undefined ? data.workspace : workspace,
3645
+ stagingLane: data.stagingLane,
3399
3646
  dataMode: data.dataMode || dataMode,
3400
3647
  shortBasePath: data.shortBasePath,
3401
3648
  embeddableUrl
@@ -3461,6 +3708,7 @@ async function toolsCommand(flags) {
3461
3708
  const { session, accountId } = sessionContext;
3462
3709
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3463
3710
  const branchName = flags.branch || currentBranch(cwd);
3711
+ const workspace = resolveWorkspace(cwd, flags);
3464
3712
  const dataMode = resolveDataMode(flags, session);
3465
3713
  const variantBranch = flags['variant-branch'];
3466
3714
  const api = buildAxios(baseUrl, session.token);
@@ -3470,6 +3718,7 @@ async function toolsCommand(flags) {
3470
3718
  accountId,
3471
3719
  variantBranch,
3472
3720
  branchName,
3721
+ workspace,
3473
3722
  dataMode
3474
3723
  }).then((r) => r.data);
3475
3724
 
@@ -3592,6 +3841,7 @@ async function toolCommand(flags) {
3592
3841
  accountId,
3593
3842
  accountIdExplicit,
3594
3843
  branchName,
3844
+ workspace: resolveWorkspace(cwd, flags),
3595
3845
  dataMode,
3596
3846
  variantBranch,
3597
3847
  name: String(toolName),
@@ -4415,6 +4665,7 @@ function exportIssueBundle(snapshot) {
4415
4665
  agents: {
4416
4666
  localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
4417
4667
  registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
4668
+ workerCount: snapshot.agents && snapshot.agents.workers ? snapshot.agents.workers.length : 0,
4418
4669
  errors: (snapshot.agents && snapshot.agents.errors) || []
4419
4670
  },
4420
4671
  repos: (snapshot.repos || []).map((repo) => ({
@@ -4528,6 +4779,31 @@ function buildRepoSnapshot(entry) {
4528
4779
  };
4529
4780
  }
4530
4781
 
4782
+ /**
4783
+ * The first argument that was actually SUPPLIED, empty string included.
4784
+ *
4785
+ * `a || b` cannot express this: an empty string is a real value on any field that can be cleared, and it
4786
+ * is falsy, so `||` silently turns "clear this" into "say nothing about this".
4787
+ */
4788
+ function firstDefined(...values) {
4789
+ for (const value of values) {
4790
+ if (value !== undefined && value !== null) return value;
4791
+ }
4792
+ return undefined;
4793
+ }
4794
+
4795
+ /**
4796
+ * A planning flag's value, where a valueless flag means CLEAR.
4797
+ *
4798
+ * `parseArgs` reports both `--planned-in` and `--planned-in ""` as `true`, because an empty next argument
4799
+ * is indistinguishable from an absent one. A planning slot is a field an org must be able to blank, so the
4800
+ * valueless form is given that meaning rather than being sent to the server as the literal string "true".
4801
+ */
4802
+ function planningFlagValue(value) {
4803
+ if (value === undefined || value === null) return undefined;
4804
+ return value === true ? '' : value;
4805
+ }
4806
+
4531
4807
  function compactSupportTicketRow(row) {
4532
4808
  if (!row || typeof row !== 'object') {
4533
4809
  return null;
@@ -4545,9 +4821,18 @@ function compactSupportTicketRow(row) {
4545
4821
  routedAgentId: row.routedAgentId || null,
4546
4822
  routedAgentLabel: row.routedAgentLabel || null,
4547
4823
  routedAt: row.routedAt || null,
4824
+ claimedAgentId: row.claimedAgentId || null,
4825
+ awaitingResponseSince: row.awaitingResponseSince || null,
4826
+ awaitingResponseQuestion: row.awaitingResponseQuestion || null,
4548
4827
  implementationAccountId: row.implementationAccountId || null,
4549
4828
  implementationAccountName: row.implementationAccountName || null,
4550
4829
  affectedComponent: row.affectedComponent || null,
4830
+ workstream: row.workstream || null,
4831
+ plannedIn: row.plannedIn || null,
4832
+ boardStage: row.boardStage || null,
4833
+ rank: row.rank === undefined ? null : row.rank,
4834
+ size: row.size || null,
4835
+ blockedBy: row.blockedBy || null,
4551
4836
  updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
4552
4837
  activityAt: row.activityAt || row.lastActivityAt || null,
4553
4838
  activityLabel: row.activityLabel || null
@@ -4565,8 +4850,13 @@ function compactSupportTicketRow(row) {
4565
4850
  async function fetchSupportTicketQueueForSession(session) {
4566
4851
  const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4567
4852
  const api = buildAxios(baseUrl, session.token, 15000);
4853
+ // The lane is PINNED, not left to the server's default. Without it the queue this row claims to
4854
+ // describe is whichever lane the platform happened to choose, so a row labelled with a session's
4855
+ // lane could be showing the other one.
4856
+ const dataMode = normalizeDataMode(session.dataMode || 'prod');
4568
4857
  const result = await api.post('/cli/tickets', {
4569
4858
  accountId: Number(session.accountId),
4859
+ dataMode,
4570
4860
  includeChildren: true,
4571
4861
  sortBy: 'triage',
4572
4862
  limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
@@ -4580,7 +4870,8 @@ async function fetchSupportTicketQueueForSession(session) {
4580
4870
  baseUrl,
4581
4871
  accountId: Number(session.accountId),
4582
4872
  userId: session.user && session.user.id ? session.user.id : null,
4583
- dataMode: (result && result.dataMode) || session.dataMode || 'prod',
4873
+ dataMode: (result && result.dataMode) || dataMode,
4874
+ compartment: baseUrl + ' · ' + ((result && result.dataMode) || dataMode) + ' lane',
4584
4875
  totalCount: (result && result.totalCount) || tickets.length,
4585
4876
  count: (result && result.count) || tickets.length,
4586
4877
  counts: (result && result.counts) || {},
@@ -4604,27 +4895,46 @@ async function fetchSupportTicketQueueForSession(session) {
4604
4895
  * Goes through the same platform endpoint an operator Embeddable would use, so "route this ticket"
4605
4896
  * has ONE implementation and one access check whichever surface asked for it.
4606
4897
  */
4607
- async function routeTicketFromDashboard({ ticketId, accountId, agentId }) {
4898
+ /**
4899
+ * Route a ticket from the dashboard, on the platform and lane that ticket actually lives on.
4900
+ *
4901
+ * <p>Selecting the session by {@code accountId} alone — and falling back to any authenticated session
4902
+ * at all — was a cross-platform write triggered from a button. Account 1 exists on production and on
4903
+ * a localhost instance both, so routing a localhost test fixture could reach production, or the
4904
+ * reverse. The ticket carries the compartment it came from; that is what decides where the write goes,
4905
+ * and if no session matches it we refuse rather than write somewhere plausible.</p>
4906
+ */
4907
+ async function routeTicketFromDashboard({ ticketId, accountId, agentId, baseUrl, dataMode }) {
4608
4908
  const numericAccountId = Number(accountId);
4609
4909
  if (!ticketId || !Number.isFinite(numericAccountId)) {
4610
4910
  throw new Error('Routing needs a ticketId and an accountId.');
4611
4911
  }
4612
- const session = listSessionsFromStore(readAllSessions())
4613
- .find((entry) => entry && entry.token && Number(entry.accountId) === numericAccountId) ||
4614
- listSessionsFromStore(readAllSessions()).find((entry) => entry && entry.token);
4912
+ const wantedBase = baseUrl ? normalizeBaseUrl(baseUrl) : null;
4913
+ const wantedMode = dataMode ? normalizeDataMode(dataMode) : null;
4914
+ const candidates = listSessionsFromStore(readAllSessions()).filter((entry) => {
4915
+ if (!entry || !entry.token) return false;
4916
+ if (wantedBase && normalizeBaseUrl(entry.baseUrl || DEFAULT_BASE_URL) !== wantedBase) return false;
4917
+ if (wantedMode && normalizeDataMode(entry.dataMode || 'prod') !== wantedMode) return false;
4918
+ return true;
4919
+ });
4920
+ const session = candidates.find((entry) => Number(entry.accountId) === numericAccountId) || candidates[0];
4615
4921
  if (!session) {
4616
- throw new Error('No authenticated session is available. Run: remits-cli auth');
4922
+ throw new Error('No authenticated session for ' + (wantedBase || 'that platform') +
4923
+ (wantedMode ? ' in the ' + wantedMode + ' lane' : '') + '. Run: remits-cli auth --base-url ' +
4924
+ (wantedBase || DEFAULT_BASE_URL));
4617
4925
  }
4618
- const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4619
- const result = await buildAxios(baseUrl, session.token, 15000)
4926
+ const targetBaseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
4927
+ const result = await buildAxios(targetBaseUrl, session.token, 15000)
4620
4928
  .post('/cli/ticketRoute', {
4621
4929
  ticketId: String(ticketId),
4622
4930
  accountId: numericAccountId,
4623
4931
  agentId: agentId || null,
4624
- dataMode: session.dataMode
4932
+ dataMode: wantedMode || session.dataMode
4625
4933
  }).then((r) => r.data);
4626
4934
  appendGlobalActivityLog('ticket', 'routed', {
4627
- ticketId, accountId: numericAccountId, agentId: agentId || null, success: result && result.success
4935
+ ticketId, accountId: numericAccountId, agentId: agentId || null,
4936
+ baseUrl: targetBaseUrl, dataMode: wantedMode || session.dataMode,
4937
+ success: result && result.success
4628
4938
  });
4629
4939
  if (result && result.success === false) {
4630
4940
  throw new Error(result.message || 'Routing was refused.');
@@ -4657,7 +4967,12 @@ async function collectAgentSnapshot() {
4657
4967
  .post('/cli/agents', { accountId: Number(session.accountId), dataMode: session.dataMode })
4658
4968
  .then((r) => r.data);
4659
4969
  for (const agent of (result && result.agents) || []) {
4660
- if (agent && agent.agentId) byAgentId[agent.agentId] = agent;
4970
+ // Stamp the platform and lane it was found on. Agent ids are UUIDs so a collision is unlikely,
4971
+ // but a merged list with no provenance cannot tell a localhost fixture agent from a production
4972
+ // one — the same blending that made the ticket panel report 583.
4973
+ if (agent && agent.agentId) {
4974
+ byAgentId[baseUrl + '|' + agent.agentId] = { ...agent, baseUrl, lane: agent.dataMode || session.dataMode };
4975
+ }
4661
4976
  }
4662
4977
  } catch (err) {
4663
4978
  errors.push({ baseUrl, accountId: Number(session.accountId), message: describeError(err) });
@@ -4670,12 +4985,110 @@ async function collectAgentSnapshot() {
4670
4985
  registered: Object.values(byAgentId)
4671
4986
  .sort((a, b) => Number(b.lastSeenAtMs || 0) - Number(a.lastSeenAtMs || 0)),
4672
4987
  local: listLocalAgents(),
4988
+ // Workers are a purely LOCAL fact — the platform knows a ticket is claimed, not that a process on
4989
+ // this laptop is running it — so they are read from disk here rather than from any endpoint.
4990
+ workers: listLocalAgents().reduce((all, entry) => all.concat(
4991
+ listWorkers(entry.agentId).map((worker) => ({
4992
+ ...worker,
4993
+ agentId: entry.agentId,
4994
+ label: entry.label || entry.agentId,
4995
+ alive: !!(worker.pid && processIsAlive(worker.pid))
4996
+ }))), []),
4673
4997
  errors
4674
4998
  };
4675
4999
  agentSnapshotCache = { fetchedAtMs: nowMs, value };
4676
5000
  return value;
4677
5001
  }
4678
5002
 
5003
+ /**
5004
+ * Split the fetched queues into the compartments a number can honestly be reported for.
5005
+ *
5006
+ * <p>A compartment is one <b>platform and one data lane</b>. Blending them was the headline defect:
5007
+ * the panel summed a production platform's queue with a localhost test-lane queue full of suite
5008
+ * fixtures and presented the result as "OPEN". Prod showed 24; the panel showed 583.</p>
5009
+ *
5010
+ * <p>Three narrower defects rode along with it, and each would still be wrong after separating the
5011
+ * regions:</p>
5012
+ * <ul>
5013
+ * <li><b>The sum double-counted.</b> Two sessions whose account hierarchies overlap both return the
5014
+ * same ticket, and the totals were summed per queue while only the DISPLAYED list was deduped.
5015
+ * Overlap is detectable: {@code /cli/tickets} reports the accounts it actually scanned, so a
5016
+ * queue whose scanned set is contained in another's is dropped before anything is added up.</li>
5017
+ * <li><b>Ticket ids were deduped globally.</b> An id is unique within a platform, not across them —
5018
+ * so a localhost fixture with the same id as a real production ticket silently hid it. Keys are
5019
+ * per compartment now.</li>
5020
+ * <li><b>"Waiting" was total minus routed</b>, which is <i>unrouted</i>, not waiting. With a real
5021
+ * awaiting-a-reply concept on the record, that label was pointing at the wrong thing entirely.</li>
5022
+ * </ul>
5023
+ */
5024
+ function buildTicketCompartments(queues) {
5025
+ const byCompartment = {};
5026
+ for (const queue of queues || []) {
5027
+ const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
5028
+ if (!byCompartment[key]) {
5029
+ byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
5030
+ }
5031
+ byCompartment[key].queues.push(queue);
5032
+ }
5033
+
5034
+ return Object.values(byCompartment).map((group) => {
5035
+ // Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
5036
+ // in that one, so keeping it would count them twice. Equal sets keep the first seen.
5037
+ const scanned = group.queues.map((queue) => new Set(
5038
+ (queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
5039
+ const independent = group.queues.filter((queue, i) => {
5040
+ if (!scanned[i].size) {
5041
+ return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
5042
+ }
5043
+ return !group.queues.some((other, j) => {
5044
+ if (i === j || !scanned[j].size) return false;
5045
+ const contained = [...scanned[i]].every((id) => scanned[j].has(id));
5046
+ if (!contained) return false;
5047
+ // Identical scopes: keep the earlier one, drop the later, or both would drop each other.
5048
+ const identical = scanned[i].size === scanned[j].size;
5049
+ return identical ? j < i : true;
5050
+ });
5051
+ });
5052
+
5053
+ const ticketsById = {};
5054
+ for (const queue of independent) {
5055
+ for (const ticket of queue.tickets || []) {
5056
+ const id = String(ticket.ticketId || '');
5057
+ if (!id || ticketsById[id]) continue;
5058
+ ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
5059
+ dataMode: queue.dataMode, compartment: group.compartment };
5060
+ }
5061
+ }
5062
+ const tickets = Object.values(ticketsById);
5063
+
5064
+ const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
5065
+ const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
5066
+ const assigned = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).assigned || 0), 0);
5067
+
5068
+ return {
5069
+ compartment: group.compartment,
5070
+ baseUrl: group.baseUrl,
5071
+ dataMode: group.dataMode,
5072
+ queueCount: independent.length,
5073
+ overlappingQueuesDropped: group.queues.length - independent.length,
5074
+ // Counted from queues proven to scan disjoint account scopes, so the same ticket is never added
5075
+ // twice. `scopeUnknown` says so out loud when a queue could not report what it scanned — a count
5076
+ // that MIGHT overlap must never read as one that cannot.
5077
+ scopeUnknown: independent.some((queue) => !(queue.searchedAccounts || []).length),
5078
+ open,
5079
+ routed,
5080
+ unrouted: Math.max(0, open - routed),
5081
+ assigned,
5082
+ // Only ever counted over the page actually retrieved, and labelled as such in the UI.
5083
+ awaitingReplyOnPage: tickets.filter((t) => t.awaitingResponseSince).length,
5084
+ accounts: independent.map((queue) => ({
5085
+ accountId: queue.accountId, open: Number(queue.totalCount || 0), scanned: (queue.searchedAccounts || []).length
5086
+ })),
5087
+ tickets: tickets.slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100))
5088
+ };
5089
+ }).sort((a, b) => b.open - a.open);
5090
+ }
5091
+
4679
5092
  async function collectSupportTicketSnapshot() {
4680
5093
  const nowMs = Date.now();
4681
5094
  if (supportTicketSnapshotCache.value &&
@@ -4711,34 +5124,11 @@ async function collectSupportTicketSnapshot() {
4711
5124
  }
4712
5125
  }));
4713
5126
 
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
5127
  const value = {
4730
5128
  fetchedAt: new Date(nowMs).toISOString(),
4731
5129
  refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
4732
5130
  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)),
5131
+ compartments: buildTicketCompartments(queues),
4742
5132
  queues,
4743
5133
  errors
4744
5134
  };
@@ -4793,7 +5183,8 @@ function renderDashboardHtml(snapshot) {
4793
5183
  ':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
5184
  '*{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
5185
  '.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;}}' +
5186
+ '.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;} '+
5187
+ '.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
5188
  '</style>' +
4798
5189
  '</head><body>' +
4799
5190
  '<main>' +
@@ -4831,6 +5222,9 @@ function renderDashboardHtml(snapshot) {
4831
5222
  '<div class="section-grid">' +
4832
5223
  '<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
4833
5224
  '</div>' +
5225
+ '<div class="section-grid">' +
5226
+ '<section class="panel"><h2>Ticket Workers</h2><div id="worker-section" class="accordion"></div></section>' +
5227
+ '</div>' +
4834
5228
  '<div class="section-grid">' +
4835
5229
  '<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
4836
5230
  '</div>' +
@@ -4864,15 +5258,16 @@ function renderDashboardHtml(snapshot) {
4864
5258
  'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
4865
5259
  'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
4866
5260
  '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=[' +
5261
+ '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
5262
  '{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
5263
  '{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"},' +
5264
+ '{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")},' +
5265
+ '{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"},' +
5266
+ '{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
5267
  '{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
5268
  '{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
4874
5269
  '];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=[' +
5270
+ '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
5271
  'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
4877
5272
  'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
4878
5273
  'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
@@ -4884,19 +5279,25 @@ function renderDashboardHtml(snapshot) {
4884
5279
  '].join("");}' +
4885
5280
  '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
5281
  '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>\';}' +
5282
+ '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;}' +
5283
+ '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
5284
  '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
5285
  '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;}' +
5286
+ '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();}' +
5287
+ 'var followState={};' +
5288
+ '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>\';}' +
5289
+ '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");}}' +
5290
+ '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);}' +
5291
+ '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);});}' +
5292
+ '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
5293
  '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
5294
  '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
5295
  '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);}' +
5296
+ '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
5297
  '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
5298
  '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
5299
  '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);});' +
5300
+ '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
5301
  'renderAll(dashboardState);' +
4901
5302
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
4902
5303
  '})();' +
@@ -4929,6 +5330,172 @@ async function reconnectManagedWebsockets() {
4929
5330
  }
4930
5331
  }
4931
5332
 
5333
+ /**
5334
+ * Turn a worker's raw JSONL transcript into readable events for the dashboard.
5335
+ *
5336
+ * <p>A worker has no terminal to attach to — it is spawned with pipes, deliberately, and `codex exec`
5337
+ * / `claude -p` are non-interactive, so there is nothing to type at even if there were a tty. What a
5338
+ * reader actually wants is to FOLLOW ALONG, and for that the JSONL is strictly better than a terminal
5339
+ * would be: it is already structured, so commands, their exit codes, file changes and the agent's own
5340
+ * messages can be rendered as distinct things instead of scraped back out of ANSI output.</p>
5341
+ *
5342
+ * <p>Reads incrementally from a byte offset so the browser polls for the tail rather than re-fetching
5343
+ * a transcript that grows to megabytes.</p>
5344
+ */
5345
+ function readWorkerTranscript(logFile, fromOffset = 0) {
5346
+ const empty = { events: [], offset: 0, size: 0, missing: true };
5347
+ if (!logFile || !fs.existsSync(logFile)) {
5348
+ return empty;
5349
+ }
5350
+ // Only ever serve files from the worker log directory. The dashboard binds to 127.0.0.1, but a path
5351
+ // taken from a query string and handed to readFileSync is a file-read primitive regardless.
5352
+ const resolved = path.resolve(logFile);
5353
+ if (path.dirname(resolved) !== path.resolve(WORKER_LOG_DIR)) {
5354
+ return { ...empty, missing: false, forbidden: true };
5355
+ }
5356
+
5357
+ const size = fs.statSync(resolved).size;
5358
+ // A truncated or rotated file (size went backwards) must restart, not read from a stale offset.
5359
+ const start = Math.min(Math.max(0, Number(fromOffset) || 0), size);
5360
+ if (start >= size) {
5361
+ return { events: [], offset: size, size, missing: false };
5362
+ }
5363
+
5364
+ const fd = fs.openSync(resolved, 'r');
5365
+ let text = '';
5366
+ try {
5367
+ const length = size - start;
5368
+ const buffer = Buffer.alloc(length);
5369
+ fs.readSync(fd, buffer, 0, length, start);
5370
+ text = buffer.toString('utf8');
5371
+ } finally {
5372
+ fs.closeSync(fd);
5373
+ }
5374
+
5375
+ // Only whole lines are consumed; a partial trailing line is left for the next poll by rewinding the
5376
+ // reported offset, so a JSON object split across two reads is never dropped as unparseable.
5377
+ const lines = text.split('\n');
5378
+ const trailing = lines.pop();
5379
+ const consumed = size - Buffer.byteLength(trailing, 'utf8');
5380
+
5381
+ const events = [];
5382
+ for (const line of lines) {
5383
+ if (!line.trim()) continue;
5384
+ // describeWorkerEvent returns null for the bookkeeping frames a reader does not want
5385
+ // (thread.started, todo_list, …). Dropping them here keeps the payload a clean list.
5386
+ const described = describeWorkerEvent(line);
5387
+ if (described) {
5388
+ events.push(described);
5389
+ }
5390
+ }
5391
+ return { events, offset: consumed, size, missing: false };
5392
+ }
5393
+
5394
+ /** One transcript line as {kind, text, detail} — never throws, and never drops a line it cannot parse. */
5395
+ function describeWorkerEvent(line) {
5396
+ let event = null;
5397
+ try {
5398
+ event = JSON.parse(line);
5399
+ } catch (_) {
5400
+ // Not JSON: a plain stderr line from the agent or the shell. Worth showing verbatim — the times
5401
+ // this matters most (a crash, a missing binary) are exactly the times it is not JSON.
5402
+ return { kind: 'raw', text: line.slice(0, 4000) };
5403
+ }
5404
+ if (!event || typeof event !== 'object') {
5405
+ return { kind: 'raw', text: line.slice(0, 4000) };
5406
+ }
5407
+
5408
+ if (event.type === 'item.completed' && event.item) {
5409
+ const item = event.item;
5410
+ const kind = item.item_type || item.type;
5411
+ if (kind === 'command_execution') {
5412
+ return {
5413
+ kind: 'command',
5414
+ text: String(item.command || ''),
5415
+ exitCode: item.exit_code == null ? null : Number(item.exit_code),
5416
+ detail: String(item.aggregated_output || '').slice(0, 4000)
5417
+ };
5418
+ }
5419
+ if (kind === 'file_change') {
5420
+ return { kind: 'edit', text: 'edited files', detail: JSON.stringify(item.changes || item, null, 2).slice(0, 4000) };
5421
+ }
5422
+ if (kind === 'reasoning') return { kind: 'thinking', text: String(item.text || '').slice(0, 4000) };
5423
+ if (kind === 'agent_message') return { kind: 'message', text: String(item.text || '').slice(0, 8000) };
5424
+ if (kind === 'todo_list') return null;
5425
+ }
5426
+ if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
5427
+ for (const block of event.message.content) {
5428
+ if (block && block.type === 'tool_use') {
5429
+ const input = block.input || {};
5430
+ return {
5431
+ kind: 'command',
5432
+ text: String(block.name || 'tool') + (input.command || input.file_path || input.pattern
5433
+ ? ': ' + (input.command || input.file_path || input.pattern) : ''),
5434
+ detail: JSON.stringify(input, null, 2).slice(0, 4000)
5435
+ };
5436
+ }
5437
+ if (block && block.type === 'text' && block.text) {
5438
+ return { kind: 'message', text: String(block.text).slice(0, 8000) };
5439
+ }
5440
+ }
5441
+ }
5442
+ if (event.type === 'result') {
5443
+ return { kind: 'result', text: 'finished: ' + (event.subtype || 'done'), detail: String(event.result || '').slice(0, 4000) };
5444
+ }
5445
+ if (event.type === 'turn.completed' && event.usage) {
5446
+ return { kind: 'result', text: 'turn complete', detail: JSON.stringify(event.usage) };
5447
+ }
5448
+ // Bookkeeping frames a reader gains nothing from. `item.started` in particular is paired with an
5449
+ // `item.completed` carrying the same command plus its output and exit code, so showing both doubles
5450
+ // the transcript and puts every command on screen twice.
5451
+ if (event.type === 'thread.started' || event.type === 'turn.started' ||
5452
+ event.type === 'item.started' || event.type === 'item.updated' || event.type === 'system') {
5453
+ return null;
5454
+ }
5455
+ return { kind: 'event', text: String(event.type || 'event') };
5456
+ }
5457
+
5458
+ /** The transcript of one running or finished worker, addressed by its ticket. */
5459
+ function workerTranscriptFor(ticketId, fromOffset) {
5460
+ const id = String(ticketId || '').trim();
5461
+ if (!id) {
5462
+ return { error: 'a ticketId is required' };
5463
+ }
5464
+ let worker = null;
5465
+ for (const entry of listLocalAgents()) {
5466
+ const found = listWorkers(entry.agentId).find((w) => String(w.ticketId) === id);
5467
+ if (found) {
5468
+ worker = { ...found, agentId: entry.agentId };
5469
+ break;
5470
+ }
5471
+ }
5472
+ // A worker that has EXITED is gone from the store but its transcript is the most interesting one to
5473
+ // read, so fall back to the newest log file for that ticket.
5474
+ let logFile = worker && worker.logFile;
5475
+ if (!logFile && fs.existsSync(WORKER_LOG_DIR)) {
5476
+ const candidates = fs.readdirSync(WORKER_LOG_DIR)
5477
+ .filter((name) => name.startsWith(id + '-') && name.endsWith('.log'))
5478
+ .sort();
5479
+ if (candidates.length) {
5480
+ logFile = path.join(WORKER_LOG_DIR, candidates[candidates.length - 1]);
5481
+ }
5482
+ }
5483
+ if (!logFile) {
5484
+ return { ticketId: id, missing: true, events: [], offset: 0 };
5485
+ }
5486
+ const transcript = readWorkerTranscript(logFile, fromOffset);
5487
+ return {
5488
+ ticketId: id,
5489
+ logFile,
5490
+ running: !!(worker && worker.pid && processIsAlive(worker.pid)),
5491
+ pid: worker ? worker.pid : null,
5492
+ agentType: worker ? worker.agentType : null,
5493
+ startedAt: worker ? worker.startedAt : null,
5494
+ follow: 'tail -f ' + logFile,
5495
+ ...transcript
5496
+ };
5497
+ }
5498
+
4932
5499
  function startDashboardServer(preferredPort) {
4933
5500
  return new Promise((resolve, reject) => {
4934
5501
  const sendDashboardError = (res, err, wantsJson = true) => {
@@ -4992,7 +5559,9 @@ function startDashboardServer(preferredPort) {
4992
5559
  actionResult = await routeTicketFromDashboard({
4993
5560
  ticketId: params.get('ticketId'),
4994
5561
  accountId: params.get('accountId'),
4995
- agentId: params.get('agentId')
5562
+ agentId: params.get('agentId'),
5563
+ baseUrl: params.get('baseUrl'),
5564
+ dataMode: params.get('dataMode')
4996
5565
  });
4997
5566
  supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
4998
5567
  agentSnapshotCache = { fetchedAtMs: 0, value: null };
@@ -5018,6 +5587,20 @@ function startDashboardServer(preferredPort) {
5018
5587
  return;
5019
5588
  }
5020
5589
 
5590
+ if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
5591
+ try {
5592
+ const payload = workerTranscriptFor(
5593
+ requestUrl.searchParams.get('ticket'),
5594
+ Number(requestUrl.searchParams.get('offset')) || 0);
5595
+ res.statusCode = 200;
5596
+ res.setHeader('Content-Type', 'application/json');
5597
+ res.end(JSON.stringify(payload));
5598
+ } catch (err) {
5599
+ sendDashboardError(res, err, true);
5600
+ }
5601
+ return;
5602
+ }
5603
+
5021
5604
  if (req.method === 'GET' && requestUrl.pathname === '/') {
5022
5605
  try {
5023
5606
  const snapshot = await collectDashboardSnapshot();
@@ -5451,58 +6034,296 @@ const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5
5451
6034
  const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
5452
6035
  const AGENT_STATES = ['idle', 'working', 'paused'];
5453
6036
 
5454
- function readAgentStore() {
5455
- if (!fs.existsSync(AGENT_STATE_FILE)) {
5456
- return { agents: {} };
6037
+ // ---------------------------------------------------------------------------------------------
6038
+ // Autonomous ticket workers
6039
+ //
6040
+ // A registered agent can also SERVE: a detached supervisor polls for the tickets routed to this
6041
+ // session and launches ONE fresh headless agent process per ticket.
6042
+ //
6043
+ // One ticket = one process is the whole point, and it is what makes context grooming structural
6044
+ // rather than a discipline. `/clear` and `/new` are TUI commands a human types; a model cannot
6045
+ // invoke them, so a long-lived interactive session working ticket after ticket has no way to reset
6046
+ // itself. A process that exits has nothing to reset.
6047
+ //
6048
+ // The supervisor is deliberately dumb: it starts workers, reports what they are doing, and cleans
6049
+ // up after them. Every decision that must survive it — which session a ticket is routed to, which
6050
+ // worker holds it — is durable on the platform, so killing the supervisor loses nothing but time.
6051
+ // ---------------------------------------------------------------------------------------------
6052
+
6053
+ const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
6054
+ const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
6055
+ const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
6056
+ // After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
6057
+ // should not hold a request-per-20s against production for work that arrives twice a day.
6058
+ const AGENT_SERVE_IDLE_AFTER_POLLS = 10;
6059
+ const AGENT_WORKER_MAX_ATTEMPTS = Number(process.env.REMITS_CLI_AGENT_WORKER_MAX_ATTEMPTS || 2);
6060
+ const AGENT_WORKER_STATUS_THROTTLE_MS = 20000;
6061
+ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
6062
+
6063
+ /**
6064
+ * How each supported agent is invoked headlessly, and how far it is allowed to go.
6065
+ *
6066
+ * Every one of them takes the brief on STDIN rather than in argv: a brief is a page of prose and
6067
+ * argv has a hard length limit, so passing it as an argument fails on exactly the detailed tickets
6068
+ * that most need the detail.
6069
+ *
6070
+ * `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
6071
+ * read-only mode so an operator can put a real boundary around a production support agent; `edit`
6072
+ * lets it change files but never commits (the brief says so, and nothing here runs git).
6073
+ */
6074
+ const AGENT_LAUNCHERS = {
6075
+ codex: {
6076
+ binary: 'codex',
6077
+ build: ({ cwd, mode, lastMessageFile }) => ({
6078
+ args: [
6079
+ 'exec',
6080
+ '--cd', cwd,
6081
+ '--skip-git-repo-check',
6082
+ '--sandbox', mode === 'investigate' ? 'read-only' : 'workspace-write',
6083
+ '--json',
6084
+ ...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
6085
+ '-'
6086
+ ]
6087
+ })
6088
+ },
6089
+ claude: {
6090
+ binary: 'claude',
6091
+ build: ({ cwd, mode }) => ({
6092
+ args: [
6093
+ '-p',
6094
+ '--add-dir', cwd,
6095
+ '--output-format', 'stream-json',
6096
+ '--verbose',
6097
+ '--permission-mode', mode === 'investigate' ? 'plan' : 'acceptEdits'
6098
+ ]
6099
+ })
6100
+ },
6101
+ gemini: {
6102
+ binary: 'gemini',
6103
+ // gemini's -p takes the prompt as a value and APPENDS it to stdin, so the brief still travels on
6104
+ // stdin and -p carries only the instruction to act on it.
6105
+ build: ({ mode }) => ({
6106
+ args: [
6107
+ '-p', 'Work the support ticket described above, following its instructions exactly.',
6108
+ '--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
6109
+ '--output-format', 'stream-json'
6110
+ ]
6111
+ })
5457
6112
  }
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: {} };
6113
+ };
6114
+
6115
+ /**
6116
+ * Which supported agent IS this process, judged from the anchor's command line.
6117
+ *
6118
+ * The binary name leads: an agent's command line routinely mentions another agent's name in a path
6119
+ * (`/Users/me/.codex/skills/...` while running claude), so a plain substring scan over the whole line
6120
+ * picks the wrong one. Returns null for an anchor that is not a supported worker — `cursor-agent` and
6121
+ * `aider` can anchor a session but cannot be launched as workers — so the caller falls back rather
6122
+ * than guessing.
6123
+ */
6124
+ function detectAgentKind(command) {
6125
+ const line = String(command || '').trim();
6126
+ if (!line) {
6127
+ return null;
5463
6128
  }
6129
+ const binary = path.basename(line.split(/\s+/)[0] || '').toLowerCase();
6130
+ const exact = SUPPORTED_AGENTS.find((agent) => binary === agent || binary.startsWith(agent + '-'));
6131
+ if (exact) {
6132
+ return exact;
6133
+ }
6134
+ return SUPPORTED_AGENTS.find((agent) => new RegExp('(^|[/\\s])' + agent + '($|[\\s])', 'i').test(line)) || null;
5464
6135
  }
5465
6136
 
5466
- function writeAgentStore(store) {
5467
- ensureSessionDir();
5468
- atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
6137
+ /**
6138
+ * Which agent a worker should be launched as.
6139
+ *
6140
+ * Precedence: an explicit flag, then WHAT THIS SESSION ACTUALLY IS, then the configured preference,
6141
+ * then the default. The anchor beats the config because it is specific to this session, and because
6142
+ * the alternative is the surprise this ordering exists to remove: `agent serve` in a Claude tab
6143
+ * labelling itself `claude@repo` while launching codex workers, since the label already detected
6144
+ * from the anchor and only the worker choice read the config. One question, one answer.
6145
+ */
6146
+ function workerAgentType(value, anchor = null) {
6147
+ return resolveWorkerAgent(value, anchor).agent;
5469
6148
  }
5470
6149
 
5471
- function processIsAlive(pid) {
5472
- const numeric = Number(pid);
5473
- if (!Number.isFinite(numeric) || numeric <= 0) {
5474
- return false;
6150
+ /**
6151
+ * The worker choice AND why it was made.
6152
+ *
6153
+ * The reason is reported, not just the value, because the intended host for a supervisor is a plain
6154
+ * terminal tab — where nothing in the process tree is an AI agent, so the choice comes from a config
6155
+ * file the operator may have set months ago and forgotten. "codex" on its own is indistinguishable
6156
+ * from a default; "codex (from your ~/.remits-cli/config.json)" is a fact they can act on.
6157
+ */
6158
+ function resolveWorkerAgent(value, anchor = null) {
6159
+ const candidate = String(value || '').trim().toLowerCase();
6160
+ if (SUPPORTED_AGENTS.includes(candidate)) {
6161
+ return { agent: candidate, source: 'flag', why: 'you passed --worker-agent' };
5475
6162
  }
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';
6163
+ const detected = detectAgentKind(anchor && anchor.command);
6164
+ if (detected) {
6165
+ return { agent: detected, source: 'session', why: 'this session is running ' + detected };
5482
6166
  }
6167
+ const config = readConfig();
6168
+ if (SUPPORTED_AGENTS.includes(String(config.agent || '').toLowerCase())) {
6169
+ return {
6170
+ agent: String(config.agent).toLowerCase(),
6171
+ source: 'config',
6172
+ why: 'from ' + CONFIG_FILE + ' — override per run with --worker-agent'
6173
+ };
6174
+ }
6175
+ return {
6176
+ agent: DEFAULT_AGENT,
6177
+ source: 'default',
6178
+ why: 'nothing configured and no agent in this session — pass --worker-agent to choose'
6179
+ };
6180
+ }
6181
+
6182
+ /** Where the repository for an account is checked out on THIS machine, or null. */
6183
+ function repoDirectoryForAccount(accountId) {
6184
+ const wanted = Number(accountId);
6185
+ if (!Number.isFinite(wanted) || wanted <= 0) {
6186
+ return null;
6187
+ }
6188
+ const entry = listAccountRepoEntries(readAccountRepoIndex())
6189
+ .find((candidate) => Number(candidate.accountId) === wanted);
6190
+ if (!entry || !entry.directory || !fs.existsSync(entry.directory)) {
6191
+ return null;
6192
+ }
6193
+ return entry.directory;
5483
6194
  }
5484
6195
 
5485
6196
  /**
5486
- * The locally-known agent sessions, dropping any whose anchor process has gone.
6197
+ * The working directory a ticket should be worked in.
5487
6198
  *
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.
6199
+ * The implementation account leads when the ticket names one — a defect observed on a CLIENT is
6200
+ * almost always fixed in the PLATFORM/PRODUCT repo above it — then the ticket's own account. When
6201
+ * neither is checked out here the answer is null, and the brief says so explicitly instead of
6202
+ * letting the worker start in whatever directory the supervisor happened to inherit.
5490
6203
  */
5491
- function listLocalAgents() {
5492
- const store = readAgentStore();
5493
- const alive = {};
5494
- let changed = false;
5495
- for (const [agentId, entry] of Object.entries(store.agents || {})) {
5496
- if (entry && processIsAlive(entry.anchorPid)) {
5497
- alive[agentId] = entry;
5498
- } else {
5499
- changed = true;
6204
+ function resolveTicketWorkingDirectory(ticket) {
6205
+ const facts = (ticket && ticket.briefFacts) || {};
6206
+ const candidates = [
6207
+ facts.repoAccountId,
6208
+ ticket && ticket.implementationAccountId,
6209
+ ticket && ticket.accountId
6210
+ ];
6211
+ for (const candidate of candidates) {
6212
+ const dir = repoDirectoryForAccount(candidate);
6213
+ if (dir) {
6214
+ return dir;
5500
6215
  }
5501
6216
  }
5502
- if (changed) {
5503
- writeAgentStore({ agents: alive });
5504
- }
5505
- return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
6217
+ return null;
6218
+ }
6219
+
6220
+ /**
6221
+ * Turn a worker's streamed output into something an operator can read in one line.
6222
+ *
6223
+ * All three agents emit JSONL, and all three emit a different shape, so this reads defensively and
6224
+ * returns null for anything it does not recognise rather than guessing. A wrong activity line is
6225
+ * worse than none: the dashboard is the only window into a headless run, and it has to be trusted.
6226
+ */
6227
+ function summarizeWorkerEvent(line) {
6228
+ let event = null;
6229
+ try {
6230
+ event = JSON.parse(line);
6231
+ } catch (_) {
6232
+ return null;
6233
+ }
6234
+ if (!event || typeof event !== 'object') {
6235
+ return null;
6236
+ }
6237
+
6238
+ // codex exec --json. The item's kind is spelled `type` on the versions observed and `item_type` in
6239
+ // some published examples, so read both rather than betting on one — a miss here is silent, and the
6240
+ // whole run then reports "nothing reported yet" while working perfectly.
6241
+ if (event.type === 'item.completed' && event.item) {
6242
+ const item = event.item;
6243
+ const kind = item.item_type || item.type;
6244
+ if (kind === 'command_execution' && item.command) {
6245
+ return 'ran: ' + String(item.command).slice(0, 120);
6246
+ }
6247
+ if (kind === 'file_change') {
6248
+ return 'edited files';
6249
+ }
6250
+ if ((kind === 'reasoning' || kind === 'agent_message') && item.text) {
6251
+ return String(item.text).split('\n')[0].slice(0, 120);
6252
+ }
6253
+ }
6254
+
6255
+ // claude / gemini stream-json
6256
+ if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
6257
+ for (const block of event.message.content) {
6258
+ if (block && block.type === 'tool_use' && block.name) {
6259
+ const input = block.input || {};
6260
+ const detail = input.command || input.file_path || input.pattern || input.path || '';
6261
+ return String(block.name + (detail ? ': ' + detail : '')).slice(0, 120);
6262
+ }
6263
+ if (block && block.type === 'text' && block.text) {
6264
+ return String(block.text).split('\n')[0].slice(0, 120);
6265
+ }
6266
+ }
6267
+ }
6268
+ if (event.type === 'result' && event.subtype) {
6269
+ return 'finishing: ' + event.subtype;
6270
+ }
6271
+ return null;
6272
+ }
6273
+
6274
+
6275
+ function readAgentStore() {
6276
+ if (!fs.existsSync(AGENT_STATE_FILE)) {
6277
+ return { agents: {} };
6278
+ }
6279
+ try {
6280
+ const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
6281
+ return data && typeof data === 'object' && data.agents ? data : { agents: {} };
6282
+ } catch (_) {
6283
+ return { agents: {} };
6284
+ }
6285
+ }
6286
+
6287
+ function writeAgentStore(store) {
6288
+ ensureSessionDir();
6289
+ atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
6290
+ }
6291
+
6292
+ function processIsAlive(pid) {
6293
+ const numeric = Number(pid);
6294
+ if (!Number.isFinite(numeric) || numeric <= 0) {
6295
+ return false;
6296
+ }
6297
+ try {
6298
+ process.kill(numeric, 0);
6299
+ return true;
6300
+ } catch (err) {
6301
+ // EPERM means the process exists but belongs to another user — still alive.
6302
+ return err && err.code === 'EPERM';
6303
+ }
6304
+ }
6305
+
6306
+ /**
6307
+ * The locally-known agent sessions, dropping any whose anchor process has gone.
6308
+ *
6309
+ * The store is a local convenience so a follow-up command in the same tab resolves the same agent
6310
+ * without being told its id. It is never the source of truth: the platform's registry is.
6311
+ */
6312
+ function listLocalAgents() {
6313
+ const store = readAgentStore();
6314
+ const alive = {};
6315
+ let changed = false;
6316
+ for (const [agentId, entry] of Object.entries(store.agents || {})) {
6317
+ if (entry && processIsAlive(entry.anchorPid)) {
6318
+ alive[agentId] = entry;
6319
+ } else {
6320
+ changed = true;
6321
+ }
6322
+ }
6323
+ if (changed) {
6324
+ writeAgentStore({ agents: alive });
6325
+ }
6326
+ return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
5506
6327
  }
5507
6328
 
5508
6329
  function rememberLocalAgent(entry) {
@@ -5582,11 +6403,9 @@ function resolveAgentAnchorPid() {
5582
6403
 
5583
6404
  function agentLabelDefault(anchor, cwd) {
5584
6405
  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;
6406
+ // Same resolution as the WORKER choice, deliberately: a label that says one agent while the
6407
+ // supervisor launches another is worse than no label, because it is the only thing an operator sees.
6408
+ return workerAgentType(null, anchor) + '@' + repo;
5590
6409
  }
5591
6410
 
5592
6411
  /**
@@ -5717,9 +6536,19 @@ async function agentRegisterCommand(flags, options = {}) {
5717
6536
  host: os.hostname(),
5718
6537
  pid: anchor.pid,
5719
6538
  cwd,
6539
+ // WHERE this session works, not just who it is. One account can now have several checkouts at once
6540
+ // (git worktrees) on different branches staging into different workspace lanes, so the account alone
6541
+ // no longer identifies the tree a worker would edit — and the edit lease refusal, the work map and the
6542
+ // ticket claim all report these back.
6543
+ branch: currentBranch(cwd) || undefined,
6544
+ workspace: resolveWorkspace(cwd, flags) || undefined,
5720
6545
  dataMode,
5721
6546
  accountId: Number(session.accountId) || undefined,
5722
6547
  accounts: agentAccountClaims(cwd),
6548
+ // Capacity is how many workers this session can actually RUN, which only this side knows. The
6549
+ // platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
6550
+ maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
6551
+ autonomous: options.serve ? true : undefined,
5723
6552
  state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
5724
6553
  activity: flags.activity || null,
5725
6554
  source: flags.source || 'repo'
@@ -5748,7 +6577,18 @@ async function agentRegisterCommand(flags, options = {}) {
5748
6577
  // A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
5749
6578
  // re-registering, and the daemons multiply for as long as the platform stays unreachable.
5750
6579
  if (options.spawnHeartbeat !== false) {
5751
- startAgentHeartbeatDaemon(entry);
6580
+ // Remembered so `agent release` can actually STOP it. Without the pid, deregistering only removes
6581
+ // the platform record — and the daemon's very next poll sees registered:false and re-registers,
6582
+ // so the session comes straight back and, when serving, resumes launching workers.
6583
+ const daemonPid = startAgentHeartbeatDaemon(entry, options.serve || null);
6584
+ entry.daemonPid = daemonPid;
6585
+ entry.serving = !!options.serve;
6586
+ if (options.serve) {
6587
+ entry.workerAgent = options.serve.workerAgent;
6588
+ entry.mode = options.serve.mode;
6589
+ entry.maxConcurrent = options.serve.maxConcurrent;
6590
+ }
6591
+ rememberLocalAgent(entry);
5752
6592
  }
5753
6593
 
5754
6594
  if (flagEnabled(flags.json)) {
@@ -5775,14 +6615,141 @@ async function agentRegisterCommand(flags, options = {}) {
5775
6615
  console.log(' --data-mode prod to take real support work.');
5776
6616
  }
5777
6617
  console.log('');
5778
- console.log('You are on duty. The support loop, repeated for as long as you are working:');
6618
+ if (options.serve) {
6619
+ const serve = options.serve;
6620
+ console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
6621
+ console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
6622
+ console.log(' ' + serve.workerAgentWhy);
6623
+ console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
6624
+ console.log('');
6625
+ console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
6626
+ console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
6627
+ console.log('session ever accumulates context across tickets.');
6628
+ console.log('');
6629
+ console.log(' remits-cli agent workers what is running right now');
6630
+ console.log(' remits-cli agent list who else is available');
6631
+ console.log(' remits-cli agent release stop serving and go offline');
6632
+ console.log('');
6633
+ console.log('You do not have to do anything else. This terminal is free.');
6634
+ return;
6635
+ }
6636
+ console.log('You are on duty, but nothing will start on its own. Either:');
6637
+ console.log('');
6638
+ console.log(' remits-cli agent serve # autonomous: a worker process per routed ticket');
6639
+ console.log('');
6640
+ console.log('or work the loop yourself:');
5779
6641
  console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
5780
6642
  console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
5781
6643
  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)');
6644
+ console.log(' 4. close it out via remits-cli ticket (accept -> status -> complete, ask, or release)');
5783
6645
  console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
5784
6646
  }
5785
6647
 
6648
+ /**
6649
+ * Register this session AND start working tickets autonomously.
6650
+ *
6651
+ * <p>Returns immediately, on purpose. The caller is very often an AI agent inside an interactive
6652
+ * terminal, and a command that blocked would consume the session it was supposed to free — the
6653
+ * supervisor is detached precisely so the tab that started it can go and do something else, or
6654
+ * nothing at all.</p>
6655
+ */
6656
+ async function agentServeCommand(flags) {
6657
+ const choice = resolveWorkerAgent(flags['worker-agent'] || flags.agent, resolveAgentAnchorPid());
6658
+ const workerAgent = choice.agent;
6659
+ const launcher = AGENT_LAUNCHERS[workerAgent];
6660
+ if (!commandExists(launcher.binary)) {
6661
+ throw new Error('Cannot serve with "' + workerAgent + '": the `' + launcher.binary +
6662
+ '` command is not on PATH. Install it, or pick another with --worker-agent ' +
6663
+ SUPPORTED_AGENTS.filter((a) => a !== workerAgent).join('|') + '.');
6664
+ }
6665
+ const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
6666
+ .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
6667
+
6668
+ return agentRegisterCommand(flags, {
6669
+ serve: {
6670
+ workerAgent,
6671
+ workerAgentWhy: choice.why,
6672
+ mode,
6673
+ maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
6674
+ }
6675
+ });
6676
+ }
6677
+
6678
+ /** Is this binary runnable on this machine? Checked before serving, not on the first ticket. */
6679
+ function commandExists(binary) {
6680
+ // `binary` only ever comes from AGENT_LAUNCHERS, so it is a fixed allowlist rather than user input.
6681
+ try {
6682
+ execFileSync('/bin/sh', ['-c', 'command -v ' + binary], { stdio: ['ignore', 'ignore', 'ignore'] });
6683
+ return true;
6684
+ } catch (_) {
6685
+ return false;
6686
+ }
6687
+ }
6688
+
6689
+ /**
6690
+ * Find this agent's daemon by looking for the process, when its pid was never recorded.
6691
+ *
6692
+ * `daemonPid` is only in the local store for agents registered by a build that writes it. An agent
6693
+ * registered by an EARLIER build has none — and that daemon is precisely the one that cannot be
6694
+ * stopped any other way: it is orphaned (its parent shell is long gone), it re-registers itself on
6695
+ * the next heartbeat after any deregister, and it predates `serve` so it responds to nothing else.
6696
+ * Its own command line carries the agentId, which is enough to find it.
6697
+ */
6698
+ function findAgentDaemonPid(agentId) {
6699
+ if (!agentId) {
6700
+ return null;
6701
+ }
6702
+ try {
6703
+ const listing = execFileSync('ps', ['-ax', '-o', 'pid=,command='], {
6704
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
6705
+ }).toString();
6706
+ for (const line of listing.split('\n')) {
6707
+ if (!line.includes('agent') || !line.includes('heartbeat-daemon') || !line.includes(agentId)) {
6708
+ continue;
6709
+ }
6710
+ const pid = Number((line.trim().match(/^(\d+)/) || [])[1]);
6711
+ // Never signal ourselves: `agent release` run from inside a daemon would kill the command.
6712
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
6713
+ return pid;
6714
+ }
6715
+ }
6716
+ } catch (_) {
6717
+ // ps unavailable or restricted — fall back to the recorded pid only.
6718
+ }
6719
+ return null;
6720
+ }
6721
+
6722
+ /** What this session has running right now, and where to read each run's transcript. */
6723
+ async function agentWorkersCommand(flags) {
6724
+ const agentId = resolveAgentId(flags, { optional: true });
6725
+ if (!agentId) {
6726
+ console.log('No local agent is registered in this session. Run: remits-cli agent serve');
6727
+ return;
6728
+ }
6729
+ const workers = listWorkers(agentId);
6730
+ if (flagEnabled(flags.json)) {
6731
+ console.log(JSON.stringify({ agentId, workers }, null, 2));
6732
+ return;
6733
+ }
6734
+ if (!workers.length) {
6735
+ console.log('No ticket workers are running for ' + agentId + '.');
6736
+ return;
6737
+ }
6738
+ console.log(workers.length + ' worker(s) running:');
6739
+ for (const worker of workers) {
6740
+ const alive = worker.pid && processIsAlive(worker.pid);
6741
+ console.log('');
6742
+ console.log(' ticket ' + worker.ticketId + ' [' + worker.agentType + ', pid ' + worker.pid +
6743
+ (alive ? '' : ', NOT RUNNING') + ']');
6744
+ console.log(' ' + (worker.subject || '(no subject)'));
6745
+ console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
6746
+ console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
6747
+ (worker.workspace ? ' workspace ' + worker.workspace : ''));
6748
+ console.log(' doing ' + (worker.activity || 'nothing reported yet'));
6749
+ console.log(' log ' + worker.logFile);
6750
+ }
6751
+ }
6752
+
5786
6753
  function readCliVersion() {
5787
6754
  try {
5788
6755
  return require(path.join(__dirname, 'package.json')).version || null;
@@ -5799,24 +6766,344 @@ function readCliVersion() {
5799
6766
  * appeared online while it happened to be running remits-cli would be invisible for exactly the
5800
6767
  * period it was busy working a ticket.
5801
6768
  */
5802
- function startAgentHeartbeatDaemon(entry) {
6769
+ function startAgentHeartbeatDaemon(entry, serve = null) {
5803
6770
  const { spawn } = require('child_process');
5804
- const child = spawn(process.execPath, [
6771
+ const args = [
5805
6772
  __filename, 'agent', 'heartbeat-daemon',
5806
6773
  '--agent-id', entry.agentId,
5807
6774
  '--anchor-pid', String(entry.anchorPid),
5808
6775
  '--base-url', entry.baseUrl,
5809
6776
  '--cwd', entry.cwd
5810
- ], { detached: true, stdio: 'ignore', env: { ...process.env } });
6777
+ ];
6778
+ if (serve) {
6779
+ args.push(
6780
+ '--serve', 'true',
6781
+ '--worker-agent', serve.workerAgent,
6782
+ '--mode', serve.mode,
6783
+ '--max-concurrent', String(serve.maxConcurrent));
6784
+ }
6785
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
5811
6786
  child.unref();
5812
6787
  appendGlobalActivityLog('agent', 'heartbeat.started', {
5813
6788
  agentId: entry.agentId,
5814
6789
  anchorPid: entry.anchorPid,
5815
- heartbeatPid: child.pid
6790
+ heartbeatPid: child.pid,
6791
+ serving: !!serve,
6792
+ workerAgent: serve ? serve.workerAgent : null,
6793
+ mode: serve ? serve.mode : null,
6794
+ maxConcurrent: serve ? serve.maxConcurrent : null
5816
6795
  });
5817
6796
  return child.pid;
5818
6797
  }
5819
6798
 
6799
+ const WORKER_STATE_FILE = path.join(SESSION_DIR, 'workers.json');
6800
+
6801
+ function readWorkerStore() {
6802
+ if (!fs.existsSync(WORKER_STATE_FILE)) {
6803
+ return { agents: {} };
6804
+ }
6805
+ try {
6806
+ const data = JSON.parse(fs.readFileSync(WORKER_STATE_FILE, 'utf8'));
6807
+ return data && typeof data === 'object' && data.agents ? data : { agents: {} };
6808
+ } catch (_) {
6809
+ return { agents: {} };
6810
+ }
6811
+ }
6812
+
6813
+ function writeWorkerStore(store) {
6814
+ ensureSessionDir();
6815
+ atomicWriteFile(WORKER_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
6816
+ }
6817
+
6818
+ /**
6819
+ * The workers this agent has running, as a file.
6820
+ *
6821
+ * In memory would be enough for the supervisor itself; on disk is what lets `remits-cli agent
6822
+ * workers` answer from another process, and what lets a restarted supervisor find the claims a
6823
+ * killed one left behind and drop them instead of waiting out the TTL.
6824
+ */
6825
+ function listWorkers(agentId) {
6826
+ const store = readWorkerStore();
6827
+ return Object.values((store.agents || {})[agentId] || {});
6828
+ }
6829
+
6830
+ function recordWorker(agentId, ticketId, patch) {
6831
+ const store = readWorkerStore();
6832
+ store.agents = store.agents || {};
6833
+ store.agents[agentId] = store.agents[agentId] || {};
6834
+ const existing = store.agents[agentId][ticketId] || {};
6835
+ store.agents[agentId][ticketId] = { ...existing, ...patch, ticketId };
6836
+ writeWorkerStore(store);
6837
+ return store.agents[agentId][ticketId];
6838
+ }
6839
+
6840
+ function clearWorker(agentId, ticketId) {
6841
+ const store = readWorkerStore();
6842
+ if (store.agents && store.agents[agentId] && store.agents[agentId][ticketId]) {
6843
+ delete store.agents[agentId][ticketId];
6844
+ writeWorkerStore(store);
6845
+ }
6846
+ }
6847
+
6848
+ /**
6849
+ * Attempts are counted on disk, per ticket, and survive the supervisor.
6850
+ *
6851
+ * Without this a ticket that reliably kills its worker is retried forever: the claim drops on exit,
6852
+ * the next poll sees it routed and unclaimed, and a fresh process starts into the same wall. The
6853
+ * counter is what turns that into a bounded retry ending in an honest release.
6854
+ */
6855
+ function workerAttemptCount(agentId, ticketId) {
6856
+ const store = readWorkerStore();
6857
+ const attempts = ((store.attempts || {})[agentId] || {})[ticketId];
6858
+ return Number(attempts) || 0;
6859
+ }
6860
+
6861
+ function recordWorkerAttempt(agentId, ticketId) {
6862
+ const store = readWorkerStore();
6863
+ store.attempts = store.attempts || {};
6864
+ store.attempts[agentId] = store.attempts[agentId] || {};
6865
+ const next = (Number(store.attempts[agentId][ticketId]) || 0) + 1;
6866
+ store.attempts[agentId][ticketId] = next;
6867
+ writeWorkerStore(store);
6868
+ return next;
6869
+ }
6870
+
6871
+ function clearWorkerAttempts(agentId, ticketId) {
6872
+ const store = readWorkerStore();
6873
+ if (store.attempts && store.attempts[agentId] && store.attempts[agentId][ticketId] != null) {
6874
+ delete store.attempts[agentId][ticketId];
6875
+ writeWorkerStore(store);
6876
+ }
6877
+ }
6878
+
6879
+ /** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
6880
+ async function releaseTicketClaim(context, agentId, ticket, options = {}) {
6881
+ try {
6882
+ const response = await buildAxios(context.baseUrl, context.session.token, 20000)
6883
+ .post('/cli/ticketClaim', {
6884
+ action: 'unclaim',
6885
+ agentId,
6886
+ ticketId: ticket.ticketId || ticket.id,
6887
+ accountId: Number(ticket.accountId),
6888
+ outcome: options.outcome || null,
6889
+ notes: options.notes || null,
6890
+ release: options.release === true,
6891
+ unroute: options.unroute === true
6892
+ }).then((r) => r.data);
6893
+ return response || null;
6894
+ } catch (err) {
6895
+ appendGlobalActivityLog('agent', 'worker.unclaim_failed', {
6896
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
6897
+ }, 'error');
6898
+ return null;
6899
+ }
6900
+ }
6901
+
6902
+ async function postAgentStatus(context, agentId, update) {
6903
+ try {
6904
+ await buildAxios(context.baseUrl, context.session.token, 10000)
6905
+ .post('/cli/agentHeartbeat', { agentId, ...update });
6906
+ } catch (_) {
6907
+ // Status is commentary. Losing a line of it must never interrupt a run.
6908
+ }
6909
+ }
6910
+
6911
+ /**
6912
+ * Launch one headless agent process for one ticket, and see it through to its exit.
6913
+ *
6914
+ * The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
6915
+ * the same thing; only the working directory is resolved here, because only this machine knows
6916
+ * where the account is checked out.
6917
+ */
6918
+ function spawnTicketWorker(context, ctx, ticket) {
6919
+ const { spawn } = require('child_process');
6920
+ const agentId = ctx.agentId;
6921
+ const ticketId = String(ticket.id || ticket.ticketId);
6922
+ const agentType = ctx.workerAgent;
6923
+ const launcher = AGENT_LAUNCHERS[agentType];
6924
+ const runId = ctx.runId + '-' + ticketId;
6925
+
6926
+ if (!launcher) {
6927
+ appendGlobalActivityLog('agent', 'worker.unsupported_agent', { agentId, ticketId, agentType }, 'error');
6928
+ return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
6929
+ }
6930
+
6931
+ const workingDirectory = resolveTicketWorkingDirectory(ticket);
6932
+ // The brief is rendered server-side without the local path (the platform cannot know it), so the
6933
+ // resolved directory is appended here. When nothing resolved, the server-rendered brief already
6934
+ // tells the worker to find it rather than guess — do not paper over that with a default.
6935
+ // The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
6936
+ // learn it: `components status` would show it a lane it never chose, and anything it reports about "the
6937
+ // staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
6938
+ // is the whole class of defect the lane exists to remove.
6939
+ const workerWorkspace = 'ticket-' + ticketId;
6940
+ const brief = String(ticket.brief || '') + (workingDirectory
6941
+ ? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
6942
+ : '') +
6943
+ '\n\n## Your staging lane\n\n' +
6944
+ 'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
6945
+ 'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
6946
+ 'are isolated from anyone else working this repository, and it also means a person looking at the shared\n' +
6947
+ 'lane will not see your changes — so say which lane you staged into when you report what you verified.\n';
6948
+
6949
+ fs.mkdirSync(WORKER_LOG_DIR, { recursive: true });
6950
+ const logFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.log');
6951
+ const lastMessageFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.result.txt');
6952
+ // The sandbox and the brief MUST agree, and they used not to: `--mode investigate` set the launcher's
6953
+ // sandbox while the server-rendered brief, never told, said "edit and verify freely" — so a read-only
6954
+ // worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
6955
+ // it derived from the repository's edit lease, and that is the authority. The session's own --mode can
6956
+ // only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
6957
+ const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
6958
+ ? 'investigate'
6959
+ : 'edit';
6960
+ const built = launcher.build({
6961
+ cwd: workingDirectory || ctx.cwd,
6962
+ mode: effectiveMode,
6963
+ lastMessageFile: agentType === 'codex' ? lastMessageFile : null
6964
+ });
6965
+
6966
+ const attempt = recordWorkerAttempt(agentId, ticketId);
6967
+ const child = spawn(launcher.binary, built.args, {
6968
+ cwd: workingDirectory || ctx.cwd,
6969
+ stdio: ['pipe', 'pipe', 'pipe'],
6970
+ env: {
6971
+ ...process.env,
6972
+ // A worker must never be mistaken for its supervisor. Without this a `remits-cli agent status`
6973
+ // run from inside the worker would resolve ambiguously against the local store.
6974
+ REMITS_AGENT_ID: agentId,
6975
+ REMITS_SUPPORT_TICKET_ID: ticketId,
6976
+ REMITS_WORKER_RUN_ID: runId,
6977
+ // Give the worker its OWN staging lane.
6978
+ //
6979
+ // The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
6980
+ // protects the working tree, which two processes editing the same files would corrupt regardless of
6981
+ // staging. What the lease does NOT cover is everyone else pointed at the same directory: a developer
6982
+ // iterating in that repo, or a read-only investigating worker running a diagnostic. They all shared
6983
+ // one staging lane with the editing worker, and `components stage` uploads the whole repo and
6984
+ // REPLACES the lane — so a worker staging mid-edit made the human's next test run execute the
6985
+ // worker's half-finished code, and vice versa.
6986
+ //
6987
+ // Env var rather than a flag because the worker is an arbitrary coding agent running arbitrary
6988
+ // remits-cli commands; only the environment reaches all of them. An explicit --workspace on a
6989
+ // command still wins, and `.remits-cli/workspace` in the repo does not override it.
6990
+ REMITS_WORKSPACE: workerWorkspace
6991
+ }
6992
+ });
6993
+
6994
+ recordWorker(agentId, ticketId, {
6995
+ pid: child.pid,
6996
+ runId,
6997
+ // The lane this worker was launched into. An operator reading `agent workers` otherwise has to know
6998
+ // the naming convention to work out where its staged components went.
6999
+ workspace: workerWorkspace,
7000
+ attempt,
7001
+ agentType,
7002
+ startedAt: new Date().toISOString(),
7003
+ workingDirectory: workingDirectory || null,
7004
+ accountId: ticket.accountId || null,
7005
+ subject: ticket.subject || null,
7006
+ logFile
7007
+ });
7008
+ appendGlobalActivityLog('agent', 'worker.started', {
7009
+ agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
7010
+ // Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
7011
+ // on the record rather than inferable from a sandbox flag nobody logged.
7012
+ phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
7013
+ editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
7014
+ });
7015
+ if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
7016
+ appendGlobalActivityLog('agent', 'worker.read_only', {
7017
+ agentId, ticketId, message: ticket.editLeaseMessage
7018
+ });
7019
+ }
7020
+
7021
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' });
7022
+ let logBytes = 0;
7023
+ let lastStatusAtMs = 0;
7024
+ let buffered = '';
7025
+
7026
+ const consume = (chunk) => {
7027
+ const text = chunk.toString();
7028
+ if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
7029
+ logBytes += text.length;
7030
+ logStream.write(text);
7031
+ }
7032
+ buffered += text;
7033
+ const lines = buffered.split('\n');
7034
+ buffered = lines.pop();
7035
+ for (const line of lines) {
7036
+ if (!line.trim()) continue;
7037
+ const summary = summarizeWorkerEvent(line);
7038
+ if (!summary) continue;
7039
+ const nowMs = Date.now();
7040
+ if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
7041
+ lastStatusAtMs = nowMs;
7042
+ recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
7043
+ postAgentStatus(context, agentId, {
7044
+ state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
7045
+ });
7046
+ }
7047
+ };
7048
+
7049
+ child.stdout.on('data', consume);
7050
+ child.stderr.on('data', consume);
7051
+ child.stdin.on('error', () => { /* the worker exited before reading the brief */ });
7052
+ child.stdin.write(brief);
7053
+ child.stdin.end();
7054
+
7055
+ const done = new Promise((resolve) => {
7056
+ child.on('error', (err) => {
7057
+ appendGlobalActivityLog('agent', 'worker.spawn_failed',
7058
+ { agentId, ticketId, agentType, error: describeError(err) }, 'error');
7059
+ resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
7060
+ });
7061
+ child.on('close', async (code, signal) => {
7062
+ try { logStream.end(); } catch (_) { /* nothing to do */ }
7063
+ const ok = code === 0 && !signal;
7064
+ appendGlobalActivityLog('agent', 'worker.finished',
7065
+ { agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
7066
+
7067
+ // The exit code is a weak signal: a worker can exit non-zero having resolved the ticket, and
7068
+ // zero having done nothing. So the TICKET decides — unclaim always, and only give up (release
7069
+ // it back to the queue) once it has genuinely failed enough times and is still not terminal.
7070
+ const giveUp = !ok && attempt >= AGENT_WORKER_MAX_ATTEMPTS;
7071
+ const result = await releaseTicketClaim(context, agentId, { ticketId, accountId: ticket.accountId }, {
7072
+ outcome: ok ? 'completed' : (signal ? 'killed' : 'failed'),
7073
+ release: giveUp,
7074
+ // A clean exit that left the ticket short of terminal is a DELIVERED judgement, not a failure —
7075
+ // the worker chose pending_review, or documented that it cannot be done. Keeping it routed here
7076
+ // would have the next poll start another worker that reaches the same conclusion, forever.
7077
+ unroute: ok,
7078
+ notes: giveUp
7079
+ ? ('Autonomous ' + agentType + ' worker failed ' + attempt + ' time(s); returning to the queue. Log: ' + logFile)
7080
+ : null
7081
+ });
7082
+
7083
+ const row = (result && result.ticket) || {};
7084
+ const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
7085
+ if (terminal || giveUp) {
7086
+ clearWorkerAttempts(agentId, ticketId);
7087
+ }
7088
+ clearWorker(agentId, ticketId);
7089
+ resolve({ ticketId, ok, code, signal, terminal, gaveUp: giveUp, status: row.status || null, logFile });
7090
+ });
7091
+ });
7092
+
7093
+ return { pid: child.pid, done };
7094
+ }
7095
+
7096
+ /**
7097
+ * The one detached process behind a registered agent session: presence, and optionally work.
7098
+ *
7099
+ * <p>Presence and work-collection live in the SAME process on purpose. Both need to know how many
7100
+ * workers are running — presence publishes it as capacity, collection needs it to decide how many
7101
+ * tickets to take — and two processes maintaining that number separately would disagree the moment
7102
+ * one of them stalled, with the platform believing whichever wrote last.</p>
7103
+ *
7104
+ * <p>When serving, the work poll IS the heartbeat: `/cli/agentWork` refreshes presence, publishes
7105
+ * the live worker set, renews their claims, and returns newly claimed work, all in one request.</p>
7106
+ */
5820
7107
  async function agentHeartbeatDaemonCommand(flags) {
5821
7108
  const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
5822
7109
  const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
@@ -5829,11 +7116,36 @@ async function agentHeartbeatDaemonCommand(flags) {
5829
7116
  // Re-registration must reuse the anchor it was given, never re-resolve one.
5830
7117
  process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
5831
7118
 
7119
+ const serving = flagEnabled(flags.serve);
7120
+ const ctx = {
7121
+ agentId,
7122
+ cwd,
7123
+ // The daemon is detached: its own process tree says nothing about the session it serves, so the
7124
+ // worker kind must come from the flag it was started with, never from re-detection.
7125
+ workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
7126
+ mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
7127
+ maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
7128
+ runId: 'run-' + Date.now().toString(36)
7129
+ };
7130
+ const inFlight = new Map();
7131
+
5832
7132
  const stop = async (reason) => {
5833
- appendGlobalActivityLog('agent', 'heartbeat.stopped', { agentId, anchorPid, reason });
7133
+ appendGlobalActivityLog('agent', 'heartbeat.stopped',
7134
+ { agentId, anchorPid, reason, workers: inFlight.size });
7135
+ // Kill the workers first, then drop their claims. A worker left running after its supervisor is
7136
+ // gone is unsupervised autonomy on production tickets — and its claim would then expire under a
7137
+ // process that is still writing.
7138
+ for (const [ticketId, entry] of inFlight.entries()) {
7139
+ try { process.kill(entry.pid, 'SIGTERM'); } catch (_) { /* already gone */ }
7140
+ clearWorker(agentId, ticketId);
7141
+ }
5834
7142
  forgetLocalAgent(agentId);
5835
7143
  try {
5836
7144
  const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
7145
+ for (const [ticketId, entry] of inFlight.entries()) {
7146
+ await releaseTicketClaim(context, agentId,
7147
+ { ticketId, accountId: entry.accountId }, { outcome: 'supervisor_stopped' });
7148
+ }
5837
7149
  await buildAxios(context.baseUrl, context.session.token, 5000)
5838
7150
  .post('/cli/agentDeregister', { agentId });
5839
7151
  } catch (_) {
@@ -5845,21 +7157,108 @@ async function agentHeartbeatDaemonCommand(flags) {
5845
7157
  process.on('SIGTERM', () => { stop('signal'); });
5846
7158
  process.on('SIGINT', () => { stop('signal'); });
5847
7159
 
7160
+ // A previous supervisor for this agent may have been killed mid-run, leaving claims behind. They
7161
+ // would expire on their own, but that is up to 15 minutes during which this session cannot restart
7162
+ // the work it is still routed. Clear anything whose process is demonstrably gone.
7163
+ if (serving) {
7164
+ for (const stale of listWorkers(agentId)) {
7165
+ if (stale && stale.pid && processIsAlive(stale.pid)) {
7166
+ continue;
7167
+ }
7168
+ clearWorker(agentId, stale.ticketId);
7169
+ try {
7170
+ const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
7171
+ await releaseTicketClaim(context, agentId, stale, { outcome: 'orphaned' });
7172
+ } catch (_) { /* the claim expires regardless */ }
7173
+ }
7174
+ }
7175
+
7176
+ let emptyPolls = 0;
5848
7177
  for (;;) {
5849
7178
  if (!processIsAlive(anchorPid)) {
5850
7179
  await stop('anchor_exited');
5851
7180
  return;
5852
7181
  }
7182
+ let response = null;
5853
7183
  try {
5854
7184
  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);
7185
+ const api = buildAxios(context.baseUrl, context.session.token, 30000);
7186
+
7187
+ if (serving) {
7188
+ const activeTickets = [...inFlight.keys()];
7189
+ response = await api.post('/cli/agentWork', {
7190
+ agentId,
7191
+ claim: inFlight.size < ctx.maxConcurrent,
7192
+ runId: ctx.runId,
7193
+ activeTickets,
7194
+ maxConcurrent: ctx.maxConcurrent,
7195
+ // Declared so the phase the platform derives already accounts for an operator who asked for a
7196
+ // read-only agent. Without it the server would hand out an edit lease this session will never
7197
+ // use, blocking a repository nobody is editing.
7198
+ mode: ctx.mode,
7199
+ statuses: 'all'
7200
+ }).then((r) => r.data);
7201
+
7202
+ // The CLI and the platform are released separately, so a new supervisor can meet an older
7203
+ // platform. That platform ignores `claim` and answers with EVERY routed ticket — which this loop
7204
+ // would read as "start all of these", spawning past capacity with no claim protecting any of
7205
+ // them. `supervising` is the platform confirming it understood the request, so its absence means
7206
+ // stand down rather than guess.
7207
+ if (response && response.supervising !== true) {
7208
+ if (emptyPolls === 0) {
7209
+ appendGlobalActivityLog('agent', 'serve.platform_too_old', {
7210
+ agentId,
7211
+ message: 'This platform does not support supervised claiming, so no workers will be started. ' +
7212
+ 'Update the platform, or work tickets manually with: remits-cli agent work'
7213
+ }, 'error');
7214
+ }
7215
+ emptyPolls += 1;
7216
+ await new Promise((resolve) => setTimeout(resolve, AGENT_SERVE_IDLE_POLL_MS));
7217
+ continue;
7218
+ }
7219
+
7220
+ // A swept ticket was routed at nobody and this session took it itself. Logged distinctly from a
7221
+ // routed one so an operator reading the activity log can tell "somebody handed me this" from
7222
+ // "nobody had, and I picked it up".
7223
+ const sweptIds = new Set(((response && response.sweptTicketIds) || []).map((id) => String(id)));
7224
+ if (sweptIds.size) {
7225
+ appendGlobalActivityLog('agent', 'serve.swept_unrouted', {
7226
+ agentId,
7227
+ ticketIds: [...sweptIds],
7228
+ message: 'Claimed ' + sweptIds.size + ' unrouted ticket(s) nobody had been given'
7229
+ });
7230
+ }
7231
+
7232
+ for (const ticket of (response && response.tickets) || []) {
7233
+ const ticketId = String(ticket.id || ticket.ticketId || '');
7234
+ if (!ticketId || inFlight.has(ticketId)) {
7235
+ continue;
7236
+ }
7237
+ const started = spawnTicketWorker(context, ctx, ticket);
7238
+ inFlight.set(ticketId, { pid: started.pid, accountId: ticket.accountId });
7239
+ started.done.then(() => {
7240
+ inFlight.delete(ticketId);
7241
+ postAgentStatus(context, agentId, inFlight.size
7242
+ ? { state: 'working', ticketId: [...inFlight.keys()][0] }
7243
+ : { state: 'idle', ticketId: null, activity: null, step: null });
7244
+ });
7245
+ }
7246
+ emptyPolls = (response && response.count) ? 0 : emptyPolls + 1;
7247
+ } else {
7248
+ response = await api.post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
7249
+ }
7250
+
5857
7251
  if (response && response.registered === false) {
5858
7252
  // The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
5859
7253
  // than heartbeating into a void — the tab is still very much alive.
5860
7254
  appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
5861
7255
  await agentRegisterCommand(
5862
- { 'agent-id': agentId, 'base-url': context.baseUrl, json: 'true' },
7256
+ {
7257
+ 'agent-id': agentId,
7258
+ 'base-url': context.baseUrl,
7259
+ 'max-concurrent': String(ctx.maxConcurrent),
7260
+ json: 'true'
7261
+ },
5863
7262
  { spawnHeartbeat: false }
5864
7263
  ).catch((err) => {
5865
7264
  appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
@@ -5869,7 +7268,11 @@ async function agentHeartbeatDaemonCommand(flags) {
5869
7268
  } catch (err) {
5870
7269
  appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
5871
7270
  }
5872
- await new Promise((resolve) => setTimeout(resolve, AGENT_HEARTBEAT_INTERVAL_MS));
7271
+
7272
+ const interval = !serving
7273
+ ? AGENT_HEARTBEAT_INTERVAL_MS
7274
+ : (emptyPolls >= AGENT_SERVE_IDLE_AFTER_POLLS ? AGENT_SERVE_IDLE_POLL_MS : AGENT_SERVE_POLL_MS);
7275
+ await new Promise((resolve) => setTimeout(resolve, interval));
5873
7276
  }
5874
7277
  }
5875
7278
 
@@ -5972,8 +7375,8 @@ async function agentWorkCommand(flags) {
5972
7375
  }
5973
7376
  console.log('');
5974
7377
  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) + '"}\'');
7378
+ console.log(' remits-cli ticket read --ticket ' + (tickets[0].id || tickets[0].ticketId));
7379
+ console.log(' remits-cli ticket accept --ticket ' + (tickets[0].id || tickets[0].ticketId));
5977
7380
  console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
5978
7381
  ' --activity "reproducing the issue"');
5979
7382
  return;
@@ -5983,6 +7386,334 @@ async function agentWorkCommand(flags) {
5983
7386
  }
5984
7387
  }
5985
7388
 
7389
+ /**
7390
+ * The generic ticket lifecycle — read a ticket, and move it through its workflow.
7391
+ *
7392
+ * Deliberately NOT a wrapper around any account's ticket tool. `mcp_support_ticket` belongs to one
7393
+ * particular System Account: it is owned by an account a given caller may not reach, it scopes to
7394
+ * its own account, and a partner platform need not have it at all. This talks to the platform's own
7395
+ * record, so the same commands work on every Remits platform — which is what an autonomous worker
7396
+ * needs, since its brief has to be written before anyone knows which platform it will run on.
7397
+ */
7398
+ async function ticketCommand(flags, subcommand) {
7399
+ const action = String(subcommand || 'read').toLowerCase();
7400
+ const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
7401
+ if (!ticketId) {
7402
+ printTicketHelp();
7403
+ throw new Error('A --ticket ID is required (or set REMITS_SUPPORT_TICKET_ID).');
7404
+ }
7405
+
7406
+ const local = listLocalAgents()[0] || null;
7407
+ const context = agentSessionFor(flags, local);
7408
+
7409
+ // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
7410
+ // so it is dispatched here rather than folded into the lifecycle switch.
7411
+ if (action === 'route' || action === 'unroute') {
7412
+ const agentId = action === 'unroute' ? null : String(flags.agent || flags['agent-id'] || '').trim();
7413
+ if (action === 'route' && !agentId) {
7414
+ throw new Error('route needs --agent <agentId>. See: remits-cli agent list');
7415
+ }
7416
+ const accountId = Number(flags['account-id']) || (await resolveTicketAccountId(context, ticketId));
7417
+ const result = await buildAxios(context.baseUrl, context.session.token, 20000)
7418
+ .post('/cli/ticketRoute', { ticketId, accountId, agentId, dataMode: context.dataMode })
7419
+ .then((r) => r.data)
7420
+ .catch((err) => (err && err.response && err.response.data) || null);
7421
+ if (!result || result.success === false) {
7422
+ console.error('Refused: ' + ((result && result.message) || 'routing failed'));
7423
+ process.exitCode = 1;
7424
+ return;
7425
+ }
7426
+ console.log(result.routed
7427
+ ? 'routed ticket ' + ticketId + ' to ' + (result.agentLabel || result.agentId)
7428
+ : 'cleared routing on ticket ' + ticketId);
7429
+ return;
7430
+ }
7431
+
7432
+ const payload = {
7433
+ action,
7434
+ ticketId,
7435
+ accountId: Number(flags['account-id']) || undefined,
7436
+ dataMode: context.dataMode,
7437
+ status: flags.status,
7438
+ resolution: flags.resolution,
7439
+ assignee: flags.assignee,
7440
+ notes: flags.notes,
7441
+ summary: flags.summary || flags.note,
7442
+ question: flags.question,
7443
+ context: flags.context,
7444
+ to: flags.to,
7445
+ message: flags.message || flags.answer,
7446
+ route: flags.route === 'false' ? false : undefined,
7447
+ details: flags.details,
7448
+ nextStep: flags['next-step'],
7449
+ category: flags.category,
7450
+ // Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
7451
+ // falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
7452
+ // The platform side has always supported clearing (an explicit blank nulls the field) — only the CLI
7453
+ // could not ask for it. `--planned-in` with no value now means "clear it".
7454
+ plannedIn: planningFlagValue(firstDefined(flags['planned-in'], flags.plannedIn)),
7455
+ boardStage: planningFlagValue(firstDefined(flags['board-stage'], flags.boardStage)),
7456
+ rank: planningFlagValue(flags.rank),
7457
+ size: planningFlagValue(flags.size),
7458
+ blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7459
+ workstream: planningFlagValue(flags.workstream),
7460
+ // Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
7461
+ // human's email is not a thing that can hold one. Inside a worker these come from the environment
7462
+ // the supervisor set, so the commands need no flags.
7463
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
7464
+ runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
7465
+ agentLabel: flags['agent-label'] || undefined,
7466
+ // WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
7467
+ // process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
7468
+ // (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
7469
+ ...runContextPayload(flags)
7470
+ };
7471
+
7472
+ let response;
7473
+ try {
7474
+ response = await buildAxios(context.baseUrl, context.session.token, 30000)
7475
+ .post('/cli/ticket', payload).then((r) => r.data);
7476
+ } catch (err) {
7477
+ // A lifecycle guard answers with 409 and a sentence saying what is wrong ("accept it first",
7478
+ // "already assigned to someone else"). That sentence IS the correction a caller needs, so it must
7479
+ // reach them intact rather than as an HTTP status.
7480
+ const body = err && err.response && err.response.data;
7481
+ if (body && body.message) {
7482
+ console.error('Refused: ' + body.message);
7483
+ process.exitCode = 1;
7484
+ return;
7485
+ }
7486
+ throw err;
7487
+ }
7488
+
7489
+ if (action === 'where' || action === 'context') {
7490
+ // The one run-context block, rendered by the platform so `ticket where`, `agent map`, the MCP tool and
7491
+ // the operator UI cannot paraphrase the same repository four different ways.
7492
+ const lines = (response && response.runContext && response.runContext.lines) || [];
7493
+ if (!lines.length) console.log('No run context for ticket ' + ticketId + '.');
7494
+ else lines.forEach((line) => console.log(' ' + line));
7495
+ return;
7496
+ }
7497
+
7498
+ if (flagEnabled(flags.json) || action === 'read') {
7499
+ console.log(JSON.stringify(response, null, 2));
7500
+ return;
7501
+ }
7502
+
7503
+ if (action === 'force-unlease' || action === 'force_unlease') {
7504
+ const previous = (response && response.previous) || null;
7505
+ console.log(response.released
7506
+ ? 'Edit lease on repository account ' + (response.repoAccountId || '?') + ' force-released' +
7507
+ (previous ? ' (was held by ' + (previous.agentLabel || previous.agentId) + ')' : '') + '.'
7508
+ : 'Nothing was holding the edit lease on this repository.');
7509
+ console.log(' A handoff worklog entry was recorded on ticket ' + ticketId + '.');
7510
+ return;
7511
+ }
7512
+
7513
+ if (action === 'lease' || action === 'unlease') {
7514
+ const lease = (response && response.editLease) || null;
7515
+ if (action === 'unlease') {
7516
+ console.log(response.released
7517
+ ? 'Edit lease released. Another agent can now edit this repository.'
7518
+ : 'You did not hold the edit lease on this repository; nothing to release.');
7519
+ return;
7520
+ }
7521
+ if (response.success) {
7522
+ console.log('Edit lease acquired — you may edit, stage and verify this repository.');
7523
+ console.log('Release it as soon as you are done: remits-cli ticket unlease');
7524
+ return;
7525
+ }
7526
+ // Not an error. The run continues read-only, and the message says who has it, WHERE they are working,
7527
+ // and what to do instead of waiting.
7528
+ console.log('Edit lease NOT acquired — this run stays read-only.');
7529
+ if (response.message) console.log(response.message);
7530
+ else if (lease) console.log('Held by ' + (lease.agentLabel || lease.agentId) + '.');
7531
+ const holderAt = describeRunLocation(lease);
7532
+ if (holderAt) console.log('Holder is working ' + holderAt);
7533
+ process.exitCode = 0;
7534
+ return;
7535
+ }
7536
+
7537
+ const ticket = (response && response.ticket) || {};
7538
+ console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
7539
+ (ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
7540
+ if (action === 'planning' || action === 'plan') {
7541
+ const planning = [
7542
+ ticket.workstream ? 'workstream=' + ticket.workstream : '',
7543
+ ticket.plannedIn ? 'plannedIn=' + ticket.plannedIn : '',
7544
+ ticket.boardStage ? 'boardStage=' + ticket.boardStage : '',
7545
+ ticket.rank !== undefined && ticket.rank !== null && ticket.rank !== '' ? 'rank=' + ticket.rank : '',
7546
+ ticket.size ? 'size=' + ticket.size : '',
7547
+ ticket.blockedBy ? 'blockedBy=' + ticket.blockedBy : ''
7548
+ ].filter(Boolean).join(', ');
7549
+ console.log(' planning: ' + (planning || 'cleared'));
7550
+ }
7551
+ if (ticket.awaitingResponseSince) {
7552
+ console.log(' waiting on an answer since ' + ticket.awaitingResponseSince);
7553
+ if (ticket.awaitingResponseQuestion) {
7554
+ console.log(' question: ' + String(ticket.awaitingResponseQuestion).split('\n')[0].slice(0, 160));
7555
+ }
7556
+ console.log(' answer it with: remits-cli ticket reply --ticket ' + ticketId + ' --message "..."');
7557
+ }
7558
+ }
7559
+
7560
+ /** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
7561
+ async function resolveTicketAccountId(context, ticketId) {
7562
+ const read = await buildAxios(context.baseUrl, context.session.token, 20000)
7563
+ .post('/cli/ticket', { action: 'read', ticketId, dataMode: context.dataMode })
7564
+ .then((r) => r.data)
7565
+ .catch(() => null);
7566
+ const accountId = Number(read && read.ticket && read.ticket.accountId);
7567
+ if (!Number.isFinite(accountId) || accountId <= 0) {
7568
+ throw new Error('Could not resolve the owning account for ticket ' + ticketId + '. Pass --account-id.');
7569
+ }
7570
+ return accountId;
7571
+ }
7572
+
7573
+ function printTicketHelp() {
7574
+ console.log('Usage: remits-cli ticket <action> --ticket ID');
7575
+ console.log('');
7576
+ console.log(' read The full ticket record, as JSON.');
7577
+ console.log(' where WHERE this work is happening: repository account,');
7578
+ console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
7579
+ console.log(' live claim, and your phase. Read it BEFORE you edit anything — with git worktrees and');
7580
+ console.log(' workspace lanes, two agents on one account can be in completely different trees.');
7581
+ console.log(' accept Claim it as yours before you start work.');
7582
+ console.log(' status --status in_progress|pending_review');
7583
+ console.log(' progress --summary "..." [--category investigation|fix|verification|...] [--next-step "..."]');
7584
+ console.log(' complete --resolution "what you found and did"');
7585
+ console.log(' ask --question "..." [--context "..."] [--to WHO]');
7586
+ console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
7587
+ console.log(' reply --message "..." [--route false]');
7588
+ console.log(' Answer the open question. By default this re-routes the ticket, so a fresh worker');
7589
+ console.log(' resumes with your answer already in its brief.');
7590
+ console.log(' release [--notes "..."] Hand it back to the queue.');
7591
+ console.log(' reopen | assign --assignee EMAIL');
7592
+ console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
7593
+ console.log(' unroute Clear its routing, e.g. when that session is gone.');
7594
+ console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
7595
+ console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
7596
+ console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
7597
+ console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
7598
+ console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
7599
+ console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
7600
+ console.log(' lease | unlease Take or give back the right to EDIT this ticket\'s');
7601
+ console.log(' repository. One agent at a time per account; everyone else investigates read-only.');
7602
+ console.log(' Your run already holds it if the brief said so. Take it when you started read-only');
7603
+ console.log(' and have reached an actual edit. A refusal is not an error — it names who has it,');
7604
+ console.log(' and investigating is still useful work. complete/ask/release free it for you.');
7605
+ console.log('');
7606
+ console.log(' Works on every Remits platform: it reads and writes the platform ticket record, not');
7607
+ console.log(' any one account\'s ticket tooling. Inside a worker, --ticket defaults to the ticket');
7608
+ console.log(' that worker was launched for.');
7609
+ }
7610
+
7611
+ /**
7612
+ * The run-context fields every call that RECORDS a location sends: checkout, branch, staging workspace.
7613
+ *
7614
+ * One helper so the lease, the claim and the registration cannot describe this process three ways —
7615
+ * the same reason the platform builds the tuple in exactly one class (AgentRunContext).
7616
+ */
7617
+ function runContextPayload(flags = {}) {
7618
+ const cwd = process.cwd();
7619
+ return {
7620
+ workingDirectory: cwd,
7621
+ branch: currentBranch(cwd) || undefined,
7622
+ workspace: resolveWorkspace(cwd, flags) || undefined
7623
+ };
7624
+ }
7625
+
7626
+ /**
7627
+ * WHERE a worker is working — checkout, branch, staging workspace lane — on one line.
7628
+ *
7629
+ * The platform builds the same string server-side (AgentRunContext.describeLocation) for the surfaces it
7630
+ * renders; this is the terminal's copy for rows it prints itself. The workspace is always named, `shared`
7631
+ * when it is the default lane, because "no workspace shown" and "the shared lane" were the same output
7632
+ * and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
7633
+ * each other's staged components.
7634
+ */
7635
+ function describeRunLocation(record) {
7636
+ if (!record) return '';
7637
+ const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
7638
+ const branch = record.branch || record.claimedBranch;
7639
+ const workspace = record.workspace || record.claimedWorkspace;
7640
+ if (!dir && !branch && !workspace) return '';
7641
+ const parts = [];
7642
+ if (dir) parts.push(dir);
7643
+ if (branch) parts.push('branch ' + branch);
7644
+ parts.push('workspace ' + (workspace || 'shared'));
7645
+ return parts.join(' ');
7646
+ }
7647
+
7648
+ /**
7649
+ * The live work map: which repositories are being edited, by whom, and who else is around.
7650
+ *
7651
+ * The question every agent has to answer before it starts — "can I safely edit account 33 right now" —
7652
+ * and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
7653
+ * on their own and never together, so every caller invented its own join and they disagreed.
7654
+ */
7655
+ async function agentWorkMapCommand(flags) {
7656
+ const agentId = resolveAgentId(flags, { optional: true });
7657
+ const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
7658
+ const context = agentSessionFor(flags, local);
7659
+ const accountId = flags['account-id'] ? Number(flags['account-id']) : Number(context.accountId);
7660
+
7661
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
7662
+ .post('/cli/workMap', {
7663
+ agentId,
7664
+ accountId,
7665
+ accountIds: flags['account-ids'] ? String(flags['account-ids']).split(',').map((s) => Number(s.trim())) : undefined,
7666
+ dataMode: context.dataMode,
7667
+ all: flagEnabled(flags.all)
7668
+ }).then((r) => r.data);
7669
+
7670
+ if (flagEnabled(flags.json)) {
7671
+ console.log(JSON.stringify(response, null, 2));
7672
+ return;
7673
+ }
7674
+
7675
+ const repositories = (response && response.repositories) || [];
7676
+ if (!repositories.length) {
7677
+ console.log('No repositories in scope for ' + context.baseUrl + ' (' + context.dataMode + ' lane).');
7678
+ return;
7679
+ }
7680
+ console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
7681
+ console.log('');
7682
+ for (const repo of repositories) {
7683
+ const lease = repo.editLease;
7684
+ console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
7685
+ (repo.accountType ? ' ' + repo.accountType : ''));
7686
+ if (lease) {
7687
+ const who = lease.agentLabel || lease.agentId;
7688
+ console.log(' EDIT LEASE ' + who +
7689
+ (lease.ticketId ? ' ticket ' + lease.ticketId : '') +
7690
+ (lease.heldForSeconds != null ? ' held ' + lease.heldForSeconds + 's' : '') +
7691
+ (lease.expiresInSeconds != null ? ' expires in ' + lease.expiresInSeconds + 's' : '') +
7692
+ (repo.editable ? ' (yours)' : ''));
7693
+ // The holder's checkout, branch and staging lane. Without them a refused agent cannot tell a real
7694
+ // file conflict from a holder working in an entirely different worktree.
7695
+ const holderAt = describeRunLocation(lease);
7696
+ if (holderAt) console.log(' ' + holderAt);
7697
+ } else {
7698
+ console.log(' EDIT LEASE free');
7699
+ }
7700
+ for (const agent of (repo.agents || [])) {
7701
+ // No phase means the session is present but holding nothing. Saying "investigating" there would
7702
+ // report work that is not happening.
7703
+ const phase = agent.phase ? agent.phase.padEnd(13) : 'available ';
7704
+ console.log(' ' + phase + ' ' +
7705
+ (agent.label || agent.agentId) + ' ' + (agent.state || '?') +
7706
+ (agent.activeTickets && agent.activeTickets.length ? ' tickets ' + agent.activeTickets.join(',') : '') +
7707
+ (agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
7708
+ const at = describeRunLocation(agent);
7709
+ if (at) console.log(' ' + at);
7710
+ if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
7711
+ }
7712
+ console.log('');
7713
+ }
7714
+ console.log('Only the lease holder may edit or stage a repository. Everyone else investigates read-only.');
7715
+ }
7716
+
5986
7717
  async function agentListCommand(flags) {
5987
7718
  const cwd = process.cwd();
5988
7719
  const mine = listLocalAgents()[0] || null;
@@ -6018,7 +7749,8 @@ async function agentListCommand(flags) {
6018
7749
  console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
6019
7750
  return;
6020
7751
  }
6021
- console.log(agents.length + ' agent(s) available across ' + scopeLabel + ':');
7752
+ console.log(agents.length + ' agent(s) available across ' + scopeLabel +
7753
+ ' in the ' + context.dataMode + ' lane:');
6022
7754
  for (const agent of agents) {
6023
7755
  console.log('');
6024
7756
  console.log(' ' + agent.agentId);
@@ -6026,8 +7758,18 @@ async function agentListCommand(flags) {
6026
7758
  console.log(' state ' + (agent.state || 'idle') +
6027
7759
  (agent.ticketId ? ' ticket ' + agent.ticketId : '') +
6028
7760
  ' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
7761
+ // The agent's OWN lane, not the query's. --all skips the lane filter for diagnostics, and without
7762
+ // this a test-lane session and a production one render identically in that listing. An agent at
7763
+ // capacity is likewise not routable, which is the other reason an idle-looking agent gets nothing.
7764
+ console.log(' lane ' + (agent.dataMode || 'prod') +
7765
+ ' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
7766
+ (agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
6029
7767
  if (agent.activity) console.log(' doing ' + agent.activity);
6030
- console.log(' where ' + (agent.host || '?') + ':' + (agent.cwd || '?'));
7768
+ // Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
7769
+ // `cwd`: a record written by an older platform, or still in Redis from before this field was
7770
+ // canonicalized, must not render as an unknown location.
7771
+ console.log(' where ' + (agent.host || '?') + ':' +
7772
+ (describeRunLocation(agent) || agent.workingDirectory || agent.cwd || '?'));
6031
7773
  }
6032
7774
  }
6033
7775
 
@@ -6038,19 +7780,39 @@ async function agentReleaseCommand(flags) {
6038
7780
  return;
6039
7781
  }
6040
7782
  const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
7783
+
7784
+ // Stop the supervisor BEFORE deregistering. The other order is a race the supervisor wins: it sees
7785
+ // registered:false, concludes its record expired, and re-registers the session we just retired.
7786
+ // Its own SIGTERM handler kills any workers and drops their claims, so this is a clean stop.
7787
+ let workersStopped = 0;
7788
+ const daemonPid = local.daemonPid && processIsAlive(local.daemonPid)
7789
+ ? local.daemonPid
7790
+ : findAgentDaemonPid(agentId);
7791
+ if (daemonPid) {
7792
+ workersStopped = listWorkers(agentId).length;
7793
+ try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
7794
+ }
7795
+
6041
7796
  const context = agentSessionFor(flags, local);
6042
7797
  await buildAxios(context.baseUrl, context.session.token, 10000)
6043
7798
  .post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
6044
7799
  forgetLocalAgent(agentId);
6045
7800
  console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
7801
+ if (workersStopped) {
7802
+ console.log('Stopped ' + workersStopped + ' running ticket worker(s); their claims were released so');
7803
+ console.log('another agent can pick the work up.');
7804
+ }
6046
7805
  }
6047
7806
 
6048
7807
  async function agentCommand(flags, subcommand) {
6049
7808
  switch (subcommand) {
6050
7809
  case 'register': return agentRegisterCommand(flags);
7810
+ case 'serve': case 'start': return agentServeCommand(flags);
7811
+ case 'workers': return agentWorkersCommand(flags);
6051
7812
  case 'status': return agentStatusCommand(flags);
6052
7813
  case 'work': return agentWorkCommand(flags);
6053
7814
  case 'list': case 'ls': return agentListCommand(flags);
7815
+ case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
6054
7816
  case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
6055
7817
  case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
6056
7818
  default:
@@ -6062,8 +7824,23 @@ async function agentCommand(flags, subcommand) {
6062
7824
  function printAgentHelp() {
6063
7825
  console.log('Usage: remits-cli agent <subcommand>');
6064
7826
  console.log('');
7827
+ console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
7828
+ console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
7829
+ console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
7830
+ console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
7831
+ console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
7832
+ console.log(' Closing the tab stops the agent.');
7833
+ console.log('');
7834
+ console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
7835
+ console.log(' session is running (when you start it from inside one), else your');
7836
+ console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
7837
+ console.log(' --mode investigate runs workers read-only (no file edits).');
7838
+ console.log('');
7839
+ console.log(' workers [--json] What this session is running right now.');
7840
+ console.log('');
6065
7841
  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');
7842
+ console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
7843
+ console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
6067
7844
  console.log(' directory — it registers for every account repo indexed on this machine that your');
6068
7845
  console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
6069
7846
  console.log(' production platform and the prod lane, because a support agent works real tickets.');
@@ -6078,6 +7855,9 @@ function printAgentHelp() {
6078
7855
  console.log(' Report what this session is doing, so operators can see it live.');
6079
7856
  console.log('');
6080
7857
  console.log(' list [--account-id ID] [--json] Who else is available for an account.');
7858
+ console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
7859
+ console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
7860
+ console.log(' investigates read-only. Check this before starting work that changes files.');
6081
7861
  console.log(' release Deregister this session immediately.');
6082
7862
  console.log('');
6083
7863
  console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
@@ -6502,24 +8282,31 @@ function printComponentsHelp(subcommand) {
6502
8282
  return;
6503
8283
  }
6504
8284
  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]');
8285
+ 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
8286
  console.log('');
6507
8287
  console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
6508
8288
  console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
8289
+ console.log('');
8290
+ console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
8291
+ console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
8292
+ console.log(' (entries for untouched components are left alone rather than deleted).');
6509
8293
  return;
6510
8294
  }
6511
8295
  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]');
8296
+ 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
8297
  console.log('');
6514
8298
  console.log('Shows staged entries and whether this checkout resolves/writes trunk or a variant branch.');
8299
+ console.log('Also lists every OTHER staging lane on this branch for this account/user, so you can see');
8300
+ console.log('whether another agent is staging alongside you.');
6515
8301
  return;
6516
8302
  }
6517
8303
  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]');
8304
+ 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
8305
  console.log('');
6520
8306
  console.log('Clears staged Redis entries without touching the database or git.');
6521
8307
  console.log('Use --component-type with --component-id when the id could exist in multiple component families.');
6522
8308
  console.log('Use --all for the temporary-experiment cleanup flow.');
8309
+ console.log('--all is scoped to YOUR workspace lane; it never clears another lane on the same branch.');
6523
8310
  return;
6524
8311
  }
6525
8312
  if (subcommand === 'commit') {
@@ -6530,6 +8317,9 @@ function printComponentsHelp(subcommand) {
6530
8317
  return;
6531
8318
  }
6532
8319
  console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
8320
+ console.log('');
8321
+ console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
8322
+ console.log('so several agents can iterate at once. See: remits-cli workspace --help');
6533
8323
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
6534
8324
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
6535
8325
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
@@ -6623,7 +8413,7 @@ async function main() {
6623
8413
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
6624
8414
  // Auto-start the background service if not already running.
6625
8415
  // Skip for lifecycle subcommands and help.
6626
- const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
8416
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent' || command === 'ticket';
6627
8417
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
6628
8418
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
6629
8419
  try {
@@ -6654,6 +8444,7 @@ async function main() {
6654
8444
  console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
6655
8445
  console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
6656
8446
  console.log(' remits-cli agent list [--account-id ID] # who else is available');
8447
+ console.log(' remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING what, where, right now');
6657
8448
  console.log(' remits-cli agent release # stop receiving tickets now');
6658
8449
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
6659
8450
  console.log(' remits-cli stop');
@@ -6665,9 +8456,10 @@ async function main() {
6665
8456
  console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
6666
8457
  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]');
6667
8458
  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]');
8459
+ console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
8460
+ console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
8461
+ console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
8462
+ 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
8463
  console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
6672
8464
  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
8465
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
@@ -6688,6 +8480,34 @@ async function main() {
6688
8480
  process.exit(0);
6689
8481
  }
6690
8482
 
8483
+ if (wantsHelp && (command === 'workspace' || command === 'ws')) {
8484
+ console.log('Usage: remits-cli workspace [show | use <name> | use --auto | clear]');
8485
+ console.log('');
8486
+ console.log('A WORKSPACE is an optional second isolation axis for staging, alongside the git branch.');
8487
+ console.log('Staging is keyed by (account, cli user, branch, workspace) — and account and user are fixed');
8488
+ console.log('for a given repo, so without a workspace the branch is the ONLY axis. That is why two coding');
8489
+ console.log('agents in two worktrees of the same branch overwrite each other: `components stage` uploads');
8490
+ console.log('the whole repo and replaces the lane wholesale.');
8491
+ console.log('');
8492
+ console.log('A workspace isolates staging WITHOUT changing anything else:');
8493
+ console.log(' - a commit still targets the same branch and the same owner account');
8494
+ console.log(' - the run still resolves whatever committed ComponentVariant branch the account subscribes to');
8495
+ console.log(' - only which staged entries shadow them changes');
8496
+ console.log('');
8497
+ console.log('Typical setup, one worktree per agent, all on the same branch:');
8498
+ console.log(' git worktree add ../ms-agent-a forked');
8499
+ console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
8500
+ console.log(' remits-cli components stage # isolated lane; nobody else sees it');
8501
+ console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
8502
+ console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
8503
+ console.log('');
8504
+ console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
8505
+ console.log('Use --no-workspace on a single command to target the shared lane without clearing the file.');
8506
+ console.log('');
8507
+ console.log('`.remits-cli/workspace` is per-checkout and gitignored, so a worktree keeps its own lane.');
8508
+ process.exit(0);
8509
+ }
8510
+
6691
8511
  if (wantsHelp && command === 'test') {
6692
8512
  printTestHelp();
6693
8513
  process.exit(0);
@@ -6813,11 +8633,25 @@ async function main() {
6813
8633
  return;
6814
8634
  }
6815
8635
 
8636
+ if (command === 'ticket') {
8637
+ if (wantsHelp || !subcommand) {
8638
+ printTicketHelp();
8639
+ process.exit(0);
8640
+ }
8641
+ await ticketCommand(args, subcommand);
8642
+ return;
8643
+ }
8644
+
6816
8645
  if (command === 'install') {
6817
8646
  await installSkillsCommand(args);
6818
8647
  return;
6819
8648
  }
6820
8649
 
8650
+ if (command === 'workspace' || command === 'ws') {
8651
+ await workspaceCommand(args);
8652
+ return;
8653
+ }
8654
+
6821
8655
  if (command === 'test' && subcommand === 'run') {
6822
8656
  await testCommand(args);
6823
8657
  return;