@remits/remits-cli 0.1.114 → 0.1.115

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
@@ -141,6 +141,10 @@ function hasExplicitDataModeFlag(flags) {
141
141
  );
142
142
  }
143
143
 
144
+ function dataModeFlagSource(flags) {
145
+ return hasExplicitDataModeFlag(flags) ? 'explicitFlag' : 'cliDefault';
146
+ }
147
+
144
148
  function canonicalSessionKey(accountId, baseUrl, dataMode) {
145
149
  const normalizedAccountId = Number(accountId);
146
150
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
@@ -347,6 +351,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
347
351
  // an account whose session is parked on prod cannot have a test run silently follow it. Report both
348
352
  // rather than a single number that governs only some commands.
349
353
  testRunDataMode: requestedDataMode || DEFAULT_DATA_MODE,
354
+ testRunDataModeSource: dataModeFlagSource(flags),
350
355
  baseUrl: normalizeBaseUrl((session && session.baseUrl) || requestedBaseUrl || DEFAULT_BASE_URL),
351
356
  updatedAt: session ? session.updatedAt : null,
352
357
  sessionResolutionWarning
@@ -366,7 +371,8 @@ function printSessionIdentity(identity, options = {}) {
366
371
  console.log('Branch:', identity.branchName || 'unresolved');
367
372
  console.log('Workspace:', identity.workspace || 'none (shared default lane)');
368
373
  console.log('Data mode:', (identity.dataMode || DEFAULT_DATA_MODE) + ' (applies to tool/tools/token)');
369
- console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE);
374
+ console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE,
375
+ '[' + (identity.testRunDataModeSource || 'cliDefault') + ']');
370
376
  console.log('Base URL:', identity.baseUrl);
371
377
  return;
372
378
  }
@@ -396,6 +402,7 @@ function printSessionIdentity(identity, options = {}) {
396
402
  // prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
397
403
  // than letting the tuple imply a lane it does not govern.
398
404
  console.log(' Data mode (test run):', identity.testRunDataMode,
405
+ '[' + (identity.testRunDataModeSource || 'cliDefault') + ']',
399
406
  identity.testRunDataMode === identity.dataMode ? '' : '— test run ignores the session lane; pass --data-mode prod to override');
400
407
  console.log(' Base URL:', identity.baseUrl);
401
408
  if (identity.updatedAt) {
@@ -767,14 +774,40 @@ function sha256(value) {
767
774
  return crypto.createHash('sha256').update(value).digest('hex');
768
775
  }
769
776
 
777
+ /**
778
+ * The branch this directory is on.
779
+ *
780
+ * <p><b>Git is asked FIRST, and the environment override is only a fallback.</b> It used to be the other
781
+ * way round, which made `REMITS_GIT_BRANCH` able to lie about a real branch: this value keys the component
782
+ * staging scope, the landing lease, the run context and every promotion command, so a stale export in a
783
+ * shell silently mislabelled every one of them — staging into a lane the tree is not on, and taking the
784
+ * landing lease for a branch nobody is landing. Nothing warned, because both halves looked correct.</p>
785
+ *
786
+ * <p>The override exists for the one case where git genuinely cannot answer: a DETACHED worktree, where
787
+ * `rev-parse --abbrev-ref HEAD` returns the literal `HEAD`. A ticket worker that detaches declares the
788
+ * branch it is working, and that declaration is used only where there is no real answer to contradict.
789
+ * So the legitimate use is preserved and the footgun is gone — on a normal checkout the variable now
790
+ * cannot change what anything reports.</p>
791
+ */
770
792
  function currentBranch(cwd) {
771
793
  try {
772
- return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
794
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
795
+ // `HEAD` means detached: git has no branch name to give, so a declared one is the only answer.
796
+ if (branch && branch !== 'HEAD') return branch;
797
+ return remitsGitBranchOverride() || branch;
773
798
  } catch (err) {
799
+ const override = remitsGitBranchOverride();
800
+ if (override) return override;
774
801
  return 'main';
775
802
  }
776
803
  }
777
804
 
805
+ function remitsGitBranchOverride() {
806
+ const value = process.env.REMITS_GIT_BRANCH || process.env.REMITS_BRANCH_NAME;
807
+ const branch = value == null ? '' : String(value).trim();
808
+ return branch && branch !== 'HEAD' ? branch : null;
809
+ }
810
+
778
811
  function shellQuote(value) {
779
812
  return '\'' + String(value).replace(/'/g, '\'\"\'\"\'' ) + '\'';
780
813
  }
@@ -2457,18 +2490,24 @@ async function pushComponentsCommand(flags) {
2457
2490
  // or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
2458
2491
  // the old default, and overridable.
2459
2492
  const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
2460
- const response = await loggedPost(api, cwd, '/cli/components', {
2493
+ const response = await stageOrRefuse(api, cwd, {
2461
2494
  token: session.token,
2462
2495
  accountId,
2463
2496
  branchName,
2464
2497
  workspace,
2465
2498
  dataMode,
2466
2499
  mode,
2500
+ // WHERE this stage is coming from. The platform derives a lightweight presence record from it, so an
2501
+ // interactive agent that never ran `agent register` is still visible to every headless worker in the
2502
+ // fleet — and so the edit-lease check below can tell "somebody else holds it" from "I hold it".
2503
+ ...runContextPayload(flags),
2504
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
2505
+ acknowledge: flags.acknowledge || undefined,
2467
2506
  // A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
2468
2507
  // every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
2469
2508
  replace: !changedOnly,
2470
2509
  components
2471
- }).then((r) => r.data);
2510
+ });
2472
2511
  response.changedFromWorkingTree = changedFromWorkingTree || [];
2473
2512
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
2474
2513
 
@@ -2482,9 +2521,51 @@ async function pushComponentsCommand(flags) {
2482
2521
  printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
2483
2522
  console.log('Data mode:', response.dataMode || dataMode);
2484
2523
  console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
2524
+ printComponentPolicy(response);
2485
2525
  printStageSummary(response, flags);
2486
2526
  }
2487
2527
 
2528
+ /**
2529
+ * A policy refusal is an ANSWER, not a transport failure.
2530
+ *
2531
+ * The platform answers 409 with the sentence to act on ("another agent holds the edit lease on this
2532
+ * repository and you are staging into the SHARED lane…"). That sentence must reach the caller intact —
2533
+ * an axios stack trace teaches an agent nothing and it will retry the identical call.
2534
+ */
2535
+ async function stageOrRefuse(api, cwd, payload) {
2536
+ try {
2537
+ return await loggedPost(api, cwd, '/cli/components', payload).then((r) => r.data);
2538
+ } catch (err) {
2539
+ const body = err && err.response && err.response.data;
2540
+ const status = err && err.response && err.response.status;
2541
+ if (status === 409 && body && body.message) {
2542
+ if (body.editLease) {
2543
+ console.error('Edit lease: ' + describeRunLocation(body.editLease));
2544
+ }
2545
+ const refusal = new Error(body.message);
2546
+ refusal.refused = true;
2547
+ throw refusal;
2548
+ }
2549
+ throw err;
2550
+ }
2551
+ }
2552
+
2553
+ /** What the account's rules said about this stage or commit — including "they could not be read". */
2554
+ function printComponentPolicy(response) {
2555
+ const policy = response && response.policy;
2556
+ const lease = response && response.editLease;
2557
+ if (lease) {
2558
+ console.log('Note: another agent holds the edit lease on this repository — ' +
2559
+ (describeRunLocation(lease) || 'location not recorded') + '.');
2560
+ }
2561
+ if (!policy) return;
2562
+ if (policy.error) {
2563
+ console.error('POLICY NOT ENFORCED: this account has component rules that could not be parsed: ' + policy.error);
2564
+ }
2565
+ (policy.warnings || []).forEach((warning) => console.log('Policy warning: ' + warning));
2566
+ (policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
2567
+ }
2568
+
2488
2569
  function shouldPrintFullComponentResponse(flags) {
2489
2570
  return flagEnabled(flags.verbose);
2490
2571
  }
@@ -2577,9 +2658,45 @@ function printStatusSummary(response, flags) {
2577
2658
  console.log(' (* = this command\'s lane)');
2578
2659
  }
2579
2660
 
2661
+ printAccountLanes(response);
2662
+
2580
2663
  printComponentCommandResponse('Components staging', response, flags);
2581
2664
  }
2582
2665
 
2666
+ // Every staging lane on the ACCOUNT, across branches.
2667
+ //
2668
+ // The block above only ever covered lanes on the SAME git branch, because that is all its key pattern
2669
+ // could reach. So an agent on trunk could not learn that a sibling had components staged on a variant
2670
+ // branch of the same account — and those lanes are not merely differently named, they resolve and commit
2671
+ // into different worlds. "Am I working alone" was only answerable within one branch, which is the
2672
+ // narrower half of the question.
2673
+ //
2674
+ // Marked "as of" deliberately: this comes from the lane index, not from the staging keys. The caller's own
2675
+ // lane is still reported authoritatively above.
2676
+ function printAccountLanes(response) {
2677
+ const lanes = Array.isArray(response.accountLanes) ? response.accountLanes : [];
2678
+ const others = lanes.filter((lane) => !lane.mine);
2679
+ if (!others.length) return;
2680
+
2681
+ console.log('');
2682
+ console.log('Other staging lanes on this ACCOUNT (as of the last stage/clear in each):');
2683
+ lanes.forEach((lane) => {
2684
+ // null onTrunk means the owner could not be resolved. Say "unknown" rather than guessing a world.
2685
+ const world = lane.onTrunk === true ? 'trunk'
2686
+ : lane.onTrunk === false ? 'variant branch'
2687
+ : 'branch world unknown';
2688
+ const ws = lane.workspace ? ' [ws:' + lane.workspace + ']' : '';
2689
+ const who = lane.userEmail ? ' ' + lane.userEmail : '';
2690
+ const ttl = lane.expiresInSeconds != null
2691
+ ? ' expires in ~' + Math.round(lane.expiresInSeconds / 60) + 'm' : '';
2692
+ console.log(' ' + (lane.mine ? '* ' : ' ') + lane.branchName + ws
2693
+ + ' (' + world + ') — ' + (lane.stagedCountAsOf || 0) + ' component(s)' + who + ttl);
2694
+ });
2695
+ console.log(' (* = this command\'s lane)');
2696
+ console.log(' A variant-branch lane layers over that branch\'s ComponentVariant overlays, and a commit');
2697
+ console.log(' from it writes overlays — never the trunk rows a trunk lane commits to.');
2698
+ }
2699
+
2583
2700
  // Make the working tree's meaning explicit. An agent may be standing in a TRUNK checkout or in a
2584
2701
  // VARIANT branch's checkout, and the two differ on BOTH ends of the loop: what a run resolves, and
2585
2702
  // what a commit writes. Inferring that from the branch name alone is exactly the kind of ambiguity
@@ -2623,8 +2740,8 @@ function printClearSummary(response, flags) {
2623
2740
  * `remits-cli workspace [show|use <name>|clear]`
2624
2741
  *
2625
2742
  * The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
2626
- * checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
2627
- * an agent its own isolated staging lane on the SAME branch.
2743
+ * checkout directory, which is what makes one workspace file per worktree enough to give an agent its own
2744
+ * isolated staging lane on the SAME branch.
2628
2745
  */
2629
2746
  async function workspaceCommand(flags) {
2630
2747
  const cwd = process.cwd();
@@ -2808,6 +2925,12 @@ async function syncComponentsCommand(flags) {
2808
2925
  forceTombstones,
2809
2926
  dryRun,
2810
2927
  mode: 'sync',
2928
+ // Identity and location, so the platform can take the landing lease for THIS session rather than for
2929
+ // an anonymous caller — and so a re-acquire by the same session (the CLI already took it in
2930
+ // `components commit` phase 1) succeeds instead of refusing itself.
2931
+ ...runContextPayload(flags),
2932
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
2933
+ acknowledge: flags.acknowledge || undefined,
2811
2934
  components: []
2812
2935
  };
2813
2936
 
@@ -2851,7 +2974,9 @@ async function syncComponentsCommand(flags) {
2851
2974
  failOnSyncGate(preflightGate);
2852
2975
  }
2853
2976
 
2854
- const response = await loggedPost(api, cwd, '/cli/components', syncPayload).then((r) => r.data);
2977
+ // Through the same refusal-aware door as `stage`: the landing lease and the account's commit rules both
2978
+ // answer 409 with the sentence to act on, and that sentence must reach the caller intact.
2979
+ const response = await stageOrRefuse(api, cwd, syncPayload);
2855
2980
 
2856
2981
  if (!response.success) {
2857
2982
  throw new Error(response.message || 'Server sync failed');
@@ -2871,6 +2996,7 @@ async function syncComponentsCommand(flags) {
2871
2996
  printResolvedBaseUrl(baseUrl);
2872
2997
  console.log('Data mode:', response.dataMode || dataMode);
2873
2998
  console.log('Mode:', response.mode || 'sync');
2999
+ printComponentPolicy(response);
2874
3000
  if (response.sync && response.sync.dryRun) {
2875
3001
  console.log('Dry run: no variants were written and staging was not cleared.');
2876
3002
  }
@@ -3551,6 +3677,17 @@ async function commitComponentsCommand(flags) {
3551
3677
  throw new Error('components commit does not support --dry-run because it performs local git writes. Use `remits-cli components sync --dry-run`.');
3552
3678
  }
3553
3679
 
3680
+ // Landing is serial and nothing can make it concurrent: `git add -A` sweeps a shared checkout, and the
3681
+ // platform pushes a regenerated `account-info.json` back to the branch during sync, so two commits
3682
+ // racing one branch collide on the remote.
3683
+ //
3684
+ // Taken HERE, before `git push`, and not after. Once the push has happened the platform can only refuse
3685
+ // the SYNC, and a pushed-but-unsynced branch is a state nobody is watching.
3686
+ //
3687
+ // The honest limit: this cannot stop a raw `git push` somebody runs themselves. It makes `remits-cli
3688
+ // components commit` serial, which is the path everything actually uses.
3689
+ const landing = await acquireLandingLease(flags, accountId, branchName);
3690
+ try {
3554
3691
  if (!skipGit) {
3555
3692
  console.log('Phase 1/3: local git commit/push');
3556
3693
  const status = runGit(cwd, 'git status --porcelain');
@@ -3610,6 +3747,71 @@ async function commitComponentsCommand(flags) {
3610
3747
  console.log('Verified local HEAD matches remits sync SHA:', expectedSha);
3611
3748
  }
3612
3749
  }
3750
+ } finally {
3751
+ // Give the branch back so the next agent lands in seconds rather than waiting out the TTL. Nothing
3752
+ // depends on this happening — the lease expires on its own, which is what covers the run that is
3753
+ // killed before it gets here.
3754
+ await releaseLandingLease(landing);
3755
+ }
3756
+ }
3757
+
3758
+ /**
3759
+ * Take the exclusive right to land this branch, or stop with the sentence saying who has it.
3760
+ *
3761
+ * Soft-fails on an OLDER PLATFORM that does not know the `land` mode (400/404): the CLI must keep working
3762
+ * against a platform it is ahead of, and the server takes the lease itself on the sync anyway — so the
3763
+ * worst case is that the refusal arrives one phase later than it should, not that it stops arriving.
3764
+ */
3765
+ async function acquireLandingLease(flags, accountId, branchName) {
3766
+ const cwd = process.cwd();
3767
+ const { session } = resolveSessionContext(cwd, flags);
3768
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3769
+ const payload = {
3770
+ token: session.token,
3771
+ accountId,
3772
+ branchName,
3773
+ mode: 'land',
3774
+ dataMode: resolveDataMode(flags, session),
3775
+ ...runContextPayload(flags),
3776
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
3777
+ };
3778
+ try {
3779
+ const response = await buildAxios(baseUrl, session.token, 20000)
3780
+ .post('/cli/components', payload).then((r) => r.data);
3781
+ if (response && response.success) {
3782
+ console.log('Landing lease: held on ' + branchName + ' (' +
3783
+ (response.ttlMinutes || '?') + 'm, renewed by the sync).');
3784
+ return { flags, accountId, branchName, baseUrl, session, held: true };
3785
+ }
3786
+ throw new Error((response && response.message) || 'Could not take the landing lease.');
3787
+ } catch (err) {
3788
+ const body = err && err.response && err.response.data;
3789
+ const status = err && err.response && err.response.status;
3790
+ if (body && body.message && status === 409) {
3791
+ throw new Error(body.message);
3792
+ }
3793
+ if (status === 400 || status === 404) {
3794
+ return { held: false };
3795
+ }
3796
+ if (body && body.message) {
3797
+ throw new Error(body.message);
3798
+ }
3799
+ throw err;
3800
+ }
3801
+ }
3802
+
3803
+ async function releaseLandingLease(landing) {
3804
+ if (!landing || !landing.held) return;
3805
+ try {
3806
+ await buildAxios(landing.baseUrl, landing.session.token, 20000).post('/cli/components', {
3807
+ token: landing.session.token,
3808
+ accountId: landing.accountId,
3809
+ branchName: landing.branchName,
3810
+ mode: 'unland',
3811
+ ...runContextPayload(landing.flags),
3812
+ agentId: landing.flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
3813
+ });
3814
+ } catch (_) { /* best-effort: the lease expires on its own */ }
3613
3815
  }
3614
3816
 
3615
3817
  function webSocketUrl(baseUrl) {
@@ -3717,6 +3919,7 @@ async function testCommand(flags) {
3717
3919
  const dataMode = hasExplicitDataModeFlag(flags)
3718
3920
  ? resolveDataMode(flags, null)
3719
3921
  : DEFAULT_DATA_MODE;
3922
+ const dataModeSource = dataModeFlagSource(flags);
3720
3923
  const testRef = flags.test || flags['test-id'] || flags.name;
3721
3924
 
3722
3925
  if (!testRef) {
@@ -3768,6 +3971,7 @@ async function testCommand(flags) {
3768
3971
  branchName,
3769
3972
  workspace,
3770
3973
  dataMode,
3974
+ dataModeSource,
3771
3975
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
3772
3976
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
3773
3977
  tests: names
@@ -5283,6 +5487,7 @@ async function collectAgentSnapshot() {
5283
5487
  label: entry.label || entry.agentId,
5284
5488
  alive: !!(worker.pid && processIsAlive(worker.pid))
5285
5489
  }))), []),
5490
+ recentWorkers: recentWorkerRuns(24),
5286
5491
  errors
5287
5492
  };
5288
5493
  agentSnapshotCache = { fetchedAtMs: nowMs, value };
@@ -5572,12 +5777,18 @@ function renderDashboardHtml(snapshot) {
5572
5777
  '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>\';}' +
5573
5778
  '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("");}' +
5574
5779
  'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
5575
- '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();}' +
5780
+ 'function basename(value){return String(value||"").split(/[\\\\/]/).pop();}' +
5781
+ 'function workerRunKey(run){return run.logName||basename(run.logFile)||String(run.ticketId||"");}' +
5782
+ 'function workerControls(run){const key=escapeHtml(workerRunKey(run));const ticket=escapeHtml(String(run.ticketId||""));const log=escapeHtml(run.logName||basename(run.logFile)||"");return \'<div class="wlog-bar"><button class="action-btn" data-follow-key="\'+key+\'" data-follow-ticket="\'+ticket+\'" data-follow-run="\'+log+\'">Follow</button><button class="action-btn" data-unfollow-key="\'+key+\'" disabled>Stop</button><label class="muted"><input type="checkbox" data-follow-tail="\'+key+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+key+\'"></span></div><div class="wlog" data-follow-log="\'+key+\'"><div class="muted">Press <strong>Follow</strong> to stream this transcript.</div></div>\';}' +
5783
+ 'function workerRunCard(run,index,prefix){const key=workerRunKey(run);const running=run.alive||run.running;const meta=badge(run.agentType||"worker",running?"ok":"warn")+" "+badge(running?"running":"finished",running?"ok":"warn")+" <span class=\\"muted\\">"+escapeHtml(formatTime(run.startedAt||run.updatedAt))+"</span>";const result=run.result?detailsBlock(prefix+":result:"+key,"Last message","worker closing summary",codeBlock(prefix+":result-code:"+key,run.result),false):"";const cardRows=[["Ticket",String(run.ticketId||"?")],["Doing Now",run.activity||"nothing reported"],["Started",formatTime(run.startedAt)],["Updated",formatTime(run.updatedAt)],["Pid",String(run.pid||"not running")],["Repo",run.workingDirectory||"(not recorded)"],["Target Branch",run.targetBranch||"n/a"],["Workspace",run.workspace||"n/a"],["Suggested Worktree",run.suggestedWorktree||"n/a"],["Log",run.logFile||""]];const cards=cardRows.map(function(pair){return "<div class=\\"mini-card\\"><strong>"+escapeHtml(pair[0])+"</strong>"+escapeHtml(pair[1])+"</div>";}).join("");const body="<div class=\\"stack\\"><div class=\\"mini-grid\\">"+cards+"</div>"+workerControls(run)+result+codeBlock(prefix+":tail:"+key,"tail -f "+(run.logFile||""))+"</div>";return detailsBlock(prefix+":"+key,"Ticket "+String(run.ticketId||"?")+" - "+(run.subject||basename(run.logFile)||"worker run"),meta,body,index===0);}' +
5784
+ 'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];const recent=((state.agents||{}).recentWorkers)||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Running Now</strong>\'+escapeHtml(String(workers.length))+\'</div><div class="mini-card"><strong>Recent Transcripts</strong>\'+escapeHtml(String(recent.length))+\'</div></div>\';html+=workers.length?detailsBlock("workers:live","Running workers","active headless ticket processes",workers.map(function(w,index){return workerRunCard(w,index,"worker-live");}).join(""),true):\'<div class="empty">No autonomous ticket workers are running right now.</div>\';html+=recent.length?detailsBlock("workers:recent","Recent worker runs","finished and running local transcripts",recent.map(function(w,index){return workerRunCard(w,index,"worker-history");}).join(""),true):\'<div class="empty">No worker transcripts have been recorded yet.</div>\';target.innerHTML=html;restartActiveFollows();}' +
5576
5785
  'var followState={};' +
5577
5786
  '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>\';}' +
5578
- '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");}}' +
5579
- '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);}' +
5580
- '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);});}' +
5787
+ 'function setFollowButtons(key,following){document.querySelectorAll(\'[data-follow-key="\'+key+\'"]\').forEach(function(b){b.disabled=following;});document.querySelectorAll(\'[data-unfollow-key="\'+key+\'"]\').forEach(function(b){b.disabled=!following;});}' +
5788
+ 'async function pollFollow(key){const st=followState[key];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}try{let url="/api/worker-log?ticket="+encodeURIComponent(st.ticket)+"&offset="+st.offset;if(st.logName)url+="&log="+encodeURIComponent(st.logName);const r=await fetch(url);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="\'+key+\'"]\');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){stopFollow(key,false);return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
5789
+ 'function startFollow(key,ticket,logName){if(followState[key])stopFollow(key,true);followState[key]={ticket:ticket,logName:logName,offset:0,timer:null};setFollowButtons(key,true);const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(box)box.innerHTML="";pollFollow(key);followState[key].timer=setInterval(function(){pollFollow(key);},2000);}' +
5790
+ 'function stopFollow(key,silent){const st=followState[key];if(st&&st.timer)clearInterval(st.timer);delete followState[key];setFollowButtons(key,false);const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(status&&!silent)status.textContent="stopped";}' +
5791
+ 'function restartActiveFollows(){Object.keys(followState).forEach(function(key){const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}const st=followState[key];st.offset=0;box.innerHTML="";setFollowButtons(key,true);pollFollow(key);});}' +
5581
5792
  '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;}' +
5582
5793
  '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);}' +
5583
5794
  '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("");}' +
@@ -5586,7 +5797,7 @@ function renderDashboardHtml(snapshot) {
5586
5797
  '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();}' +
5587
5798
  '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);}' +
5588
5799
  '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;}}' +
5589
- '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);});' +
5800
+ 'document.addEventListener("click",function(event){const stop=event.target.closest?event.target.closest("[data-unfollow-key]"):null;if(stop){stopFollow(stop.getAttribute("data-unfollow-key"),false);return;}const follow=event.target.closest?event.target.closest("[data-follow-key]"):null;if(follow){startFollow(follow.getAttribute("data-follow-key"),follow.getAttribute("data-follow-ticket"),follow.getAttribute("data-follow-run"));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);});' +
5590
5801
  'renderAll(dashboardState);' +
5591
5802
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
5592
5803
  '})();' +
@@ -5785,6 +5996,118 @@ function workerTranscriptFor(ticketId, fromOffset) {
5785
5996
  };
5786
5997
  }
5787
5998
 
5999
+ function workerRunFromLogName(name) {
6000
+ const match = /^(\d+)-(\d+)\.log$/.exec(String(name || ''));
6001
+ if (!match) {
6002
+ return null;
6003
+ }
6004
+ return { ticketId: match[1], timestampMs: Number(match[2]) || null };
6005
+ }
6006
+
6007
+ function resultTextForWorkerLog(logFile) {
6008
+ const parsed = workerRunFromLogName(path.basename(logFile || ''));
6009
+ if (!parsed) {
6010
+ return null;
6011
+ }
6012
+ const exact = logFile.replace(/\.log$/, '.result.txt');
6013
+ if (fs.existsSync(exact)) {
6014
+ return truncateText(fs.readFileSync(exact, 'utf8'), 4000);
6015
+ }
6016
+ const candidates = fs.existsSync(WORKER_LOG_DIR)
6017
+ ? fs.readdirSync(WORKER_LOG_DIR)
6018
+ .filter((name) => name.startsWith(parsed.ticketId + '-') && name.endsWith('.result.txt'))
6019
+ .map((name) => {
6020
+ const file = path.join(WORKER_LOG_DIR, name);
6021
+ const stat = fileStat(file);
6022
+ return stat ? { file, mtimeMs: stat.mtimeMs } : null;
6023
+ })
6024
+ .filter(Boolean)
6025
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
6026
+ : [];
6027
+ if (!candidates.length) {
6028
+ return null;
6029
+ }
6030
+ return truncateText(fs.readFileSync(candidates[0].file, 'utf8'), 4000);
6031
+ }
6032
+
6033
+ /** Recent worker transcripts, including runs whose live worker row was cleared after release/exit. */
6034
+ function recentWorkerRuns(limit = 20) {
6035
+ if (!fs.existsSync(WORKER_LOG_DIR)) {
6036
+ return [];
6037
+ }
6038
+
6039
+ const activeByLog = {};
6040
+ for (const agent of listLocalAgents()) {
6041
+ for (const worker of listWorkers(agent.agentId)) {
6042
+ if (worker && worker.logFile) {
6043
+ activeByLog[path.basename(worker.logFile)] = {
6044
+ ...worker,
6045
+ agentId: agent.agentId,
6046
+ label: agent.label || agent.agentId
6047
+ };
6048
+ }
6049
+ }
6050
+ }
6051
+
6052
+ return fs.readdirSync(WORKER_LOG_DIR)
6053
+ .map((name) => {
6054
+ const parsed = workerRunFromLogName(name);
6055
+ if (!parsed) {
6056
+ return null;
6057
+ }
6058
+ const logFile = path.join(WORKER_LOG_DIR, name);
6059
+ const stat = fileStat(logFile);
6060
+ if (!stat) {
6061
+ return null;
6062
+ }
6063
+ const active = activeByLog[name] || {};
6064
+ const alive = !!(active.pid && processIsAlive(active.pid));
6065
+ return {
6066
+ ticketId: parsed.ticketId,
6067
+ logName: name,
6068
+ logFile,
6069
+ size: stat.size,
6070
+ updatedAt: stat.mtime.toISOString(),
6071
+ startedAt: active.startedAt || (parsed.timestampMs ? new Date(parsed.timestampMs).toISOString() : null),
6072
+ agentId: active.agentId || null,
6073
+ label: active.label || null,
6074
+ agentType: active.agentType || null,
6075
+ pid: active.pid || null,
6076
+ alive,
6077
+ running: alive,
6078
+ attempt: active.attempt || null,
6079
+ workingDirectory: active.workingDirectory || null,
6080
+ targetBranch: active.targetBranch || null,
6081
+ suggestedWorktree: active.suggestedWorktree || null,
6082
+ workspace: active.workspace || null,
6083
+ subject: active.subject || null,
6084
+ result: resultTextForWorkerLog(logFile)
6085
+ };
6086
+ })
6087
+ .filter(Boolean)
6088
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
6089
+ .slice(0, Math.max(1, Number(limit) || 20));
6090
+ }
6091
+
6092
+ /** The transcript of one specific worker log, addressed by safe basename. */
6093
+ function workerTranscriptByLogName(logName, fromOffset) {
6094
+ const parsed = workerRunFromLogName(path.basename(String(logName || '')));
6095
+ if (!parsed) {
6096
+ return { error: 'a valid worker log name is required' };
6097
+ }
6098
+ const logFile = path.join(WORKER_LOG_DIR, path.basename(logName));
6099
+ const transcript = readWorkerTranscript(logFile, fromOffset);
6100
+ return {
6101
+ ticketId: parsed.ticketId,
6102
+ logName: path.basename(logName),
6103
+ logFile,
6104
+ running: false,
6105
+ pid: null,
6106
+ follow: 'tail -f ' + logFile,
6107
+ ...transcript
6108
+ };
6109
+ }
6110
+
5788
6111
  function startDashboardServer(preferredPort) {
5789
6112
  return new Promise((resolve, reject) => {
5790
6113
  const sendDashboardError = (res, err, wantsJson = true) => {
@@ -5878,9 +6201,11 @@ function startDashboardServer(preferredPort) {
5878
6201
 
5879
6202
  if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
5880
6203
  try {
5881
- const payload = workerTranscriptFor(
5882
- requestUrl.searchParams.get('ticket'),
5883
- Number(requestUrl.searchParams.get('offset')) || 0);
6204
+ const offset = Number(requestUrl.searchParams.get('offset')) || 0;
6205
+ const logName = requestUrl.searchParams.get('log');
6206
+ const payload = logName
6207
+ ? workerTranscriptByLogName(logName, offset)
6208
+ : workerTranscriptFor(requestUrl.searchParams.get('ticket'), offset);
5884
6209
  res.statusCode = 200;
5885
6210
  res.setHeader('Content-Type', 'application/json');
5886
6211
  res.end(JSON.stringify(payload));
@@ -6340,6 +6665,7 @@ const AGENT_STATES = ['idle', 'working', 'paused'];
6340
6665
  // ---------------------------------------------------------------------------------------------
6341
6666
 
6342
6667
  const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
6668
+ const TICKET_WORKTREE_DIRNAME = 'ticket-worktrees';
6343
6669
  const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
6344
6670
  const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
6345
6671
  // After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
@@ -6608,6 +6934,57 @@ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
6608
6934
  return null;
6609
6935
  }
6610
6936
 
6937
+ function gitBranchNameInDirectory(cwd) {
6938
+ if (!cwd) return null;
6939
+ try {
6940
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
6941
+ cwd,
6942
+ stdio: ['ignore', 'pipe', 'ignore']
6943
+ }).toString().trim();
6944
+ return branch && branch !== 'HEAD' ? branch : null;
6945
+ } catch (_) {
6946
+ return null;
6947
+ }
6948
+ }
6949
+
6950
+ function defaultGitBranchInDirectory(cwd) {
6951
+ if (!cwd) return null;
6952
+ try {
6953
+ const branch = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
6954
+ cwd,
6955
+ stdio: ['ignore', 'pipe', 'ignore']
6956
+ }).toString().trim().replace(/^[^/]+\//, '');
6957
+ return branch && branch !== 'HEAD' ? branch : null;
6958
+ } catch (_) {
6959
+ return null;
6960
+ }
6961
+ }
6962
+
6963
+ function ticketTargetBranch(ticket, baseDirectory) {
6964
+ const facts = (ticket && ticket.briefFacts) || {};
6965
+ const candidates = [
6966
+ facts.componentBranch,
6967
+ ticket && ticket.componentBranch,
6968
+ facts.branch,
6969
+ ticket && ticket.branch,
6970
+ defaultGitBranchInDirectory(baseDirectory),
6971
+ gitBranchNameInDirectory(baseDirectory)
6972
+ ];
6973
+ for (const candidate of candidates) {
6974
+ const branch = candidate == null ? '' : String(candidate).trim();
6975
+ if (branch && branch !== 'HEAD') return branch;
6976
+ }
6977
+ // No guess. This used to fall back to 'main', which is a guess presented to the worker as a fact — and
6978
+ // wrong on any repo whose default is `master`, or whose work belongs on a variant branch. A worker told
6979
+ // the wrong target branch does not fail; it does the work in the wrong place and reports success.
6980
+ // Absent is a real answer, and the brief omits the line rather than naming a branch nobody resolved.
6981
+ return null;
6982
+ }
6983
+
6984
+ function ticketWorktreePath(baseDirectory, ticketId) {
6985
+ return path.join(baseDirectory, '.remits-cli', TICKET_WORKTREE_DIRNAME, 'ticket-' + ticketId);
6986
+ }
6987
+
6611
6988
  /**
6612
6989
  * Which account this directory is a checkout OF, read from the directory itself.
6613
6990
  *
@@ -6718,6 +7095,21 @@ function processIsAlive(pid) {
6718
7095
  }
6719
7096
  }
6720
7097
 
7098
+ async function waitForProcessExit(pid, timeoutMs = 5000) {
7099
+ const numeric = Number(pid);
7100
+ if (!Number.isFinite(numeric) || numeric <= 0) {
7101
+ return true;
7102
+ }
7103
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
7104
+ while (Date.now() < deadline) {
7105
+ if (!processIsAlive(numeric)) {
7106
+ return true;
7107
+ }
7108
+ await new Promise((resolve) => setTimeout(resolve, 100));
7109
+ }
7110
+ return !processIsAlive(numeric);
7111
+ }
7112
+
6721
7113
  /**
6722
7114
  * The locally-known agent sessions, dropping any whose anchor process has gone.
6723
7115
  *
@@ -6921,6 +7313,23 @@ function agentAccountClaims(cwd) {
6921
7313
  return claims;
6922
7314
  }
6923
7315
 
7316
+ /**
7317
+ * `--serves support,incident` -> ['support','incident'].
7318
+ *
7319
+ * Opaque strings: an account's workstream names are its own and neither the CLI nor the platform learns
7320
+ * what any of them means. Lower-cased and de-duplicated here so one machine's `--serves SDLC` and
7321
+ * another's `--serves sdlc` are the same declaration rather than two queues that never overlap.
7322
+ */
7323
+ function parseServedWorkstreams(value) {
7324
+ if (value === undefined || value === null || value === true) {
7325
+ return [];
7326
+ }
7327
+ return String(value)
7328
+ .split(',')
7329
+ .map((entry) => entry.trim().toLowerCase())
7330
+ .filter((entry, index, all) => entry && all.indexOf(entry) === index);
7331
+ }
7332
+
6924
7333
  async function agentRegisterCommand(flags, options = {}) {
6925
7334
  const cwd = process.cwd();
6926
7335
  try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
@@ -6964,6 +7373,15 @@ async function agentRegisterCommand(flags, options = {}) {
6964
7373
  // platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
6965
7374
  maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
6966
7375
  autonomous: options.serve ? true : undefined,
7376
+ // The autonomy bound and the queues this session covers, on the PRESENCE record rather than only on
7377
+ // the work poll. Routing chooses from presence, so without these `dispatch(...)` would hand a code
7378
+ // change to a session launched read-only while an edit-capable one sat idle, and would ignore
7379
+ // `--serves` entirely. Both are omitted rather than sent empty when not declared, so a re-register
7380
+ // after a TTL lapse cannot silently un-specialise a machine.
7381
+ mode: options.serve ? options.serve.mode : undefined,
7382
+ serves: (options.serve && options.serve.serves && options.serve.serves.length)
7383
+ ? options.serve.serves
7384
+ : (parseServedWorkstreams(flags.serves).length ? parseServedWorkstreams(flags.serves) : undefined),
6967
7385
  state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
6968
7386
  activity: flags.activity || null,
6969
7387
  source: flags.source || 'repo'
@@ -7001,6 +7419,7 @@ async function agentRegisterCommand(flags, options = {}) {
7001
7419
  if (options.serve) {
7002
7420
  entry.workerAgent = options.serve.workerAgent;
7003
7421
  entry.mode = options.serve.mode;
7422
+ entry.serves = options.serve.serves || [];
7004
7423
  entry.maxConcurrent = options.serve.maxConcurrent;
7005
7424
  }
7006
7425
  rememberLocalAgent(entry);
@@ -7036,6 +7455,9 @@ async function agentRegisterCommand(flags, options = {}) {
7036
7455
  console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
7037
7456
  console.log(' ' + serve.workerAgentWhy);
7038
7457
  console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
7458
+ console.log(' serves ' + ((serve.serves && serve.serves.length)
7459
+ ? serve.serves.join(', ') + ' (only these workstreams are routed here, and only these are swept)'
7460
+ : 'every workstream on these accounts'));
7039
7461
  console.log('');
7040
7462
  console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
7041
7463
  console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
@@ -7079,6 +7501,16 @@ async function agentServeCommand(flags) {
7079
7501
  }
7080
7502
  const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
7081
7503
  .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
7504
+ const serves = parseServedWorkstreams(flags.serves);
7505
+ if (serves.length) {
7506
+ console.log('');
7507
+ console.log('Serving workstreams: ' + serves.join(', '));
7508
+ console.log(' This session is offered ONLY tickets in those workstreams, and its sweep takes only');
7509
+ console.log(' those. A workstream no live session covers is routed to nobody — the platform reports');
7510
+ console.log(' that as no_agent_serves_workstream rather than quietly handing it here anyway.');
7511
+ console.log(' Drop --serves to serve every workstream on these accounts.');
7512
+ console.log('');
7513
+ }
7082
7514
 
7083
7515
  // Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
7084
7516
  // pointed at an account with no usable checkout parks correctly, but the operator only learns that
@@ -7114,6 +7546,7 @@ async function agentServeCommand(flags) {
7114
7546
  workerAgent,
7115
7547
  workerAgentWhy: choice.why,
7116
7548
  mode,
7549
+ serves,
7117
7550
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
7118
7551
  }
7119
7552
  });
@@ -7188,7 +7621,11 @@ async function agentWorkersCommand(flags) {
7188
7621
  console.log(' ' + (worker.subject || '(no subject)'));
7189
7622
  console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
7190
7623
  console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
7624
+ (worker.targetBranch ? ' target branch ' + worker.targetBranch : '') +
7191
7625
  (worker.workspace ? ' workspace ' + worker.workspace : ''));
7626
+ if (worker.suggestedWorktree) {
7627
+ console.log(' suggest ' + worker.suggestedWorktree);
7628
+ }
7192
7629
  console.log(' doing ' + (worker.activity || 'nothing reported yet'));
7193
7630
  console.log(' log ' + worker.logFile);
7194
7631
  }
@@ -7225,6 +7662,13 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
7225
7662
  '--worker-agent', serve.workerAgent,
7226
7663
  '--mode', serve.mode,
7227
7664
  '--max-concurrent', String(serve.maxConcurrent));
7665
+ // The daemon is DETACHED: nothing about its process tree recovers what the operator typed, so any
7666
+ // declaration that must survive into the supervisor loop has to travel on the command line. Omitted
7667
+ // when empty so the daemon's own `parseServedWorkstreams` sees "not declared" rather than "declared
7668
+ // as nothing", which are different facts on the registration payload.
7669
+ if (serve.serves && serve.serves.length) {
7670
+ args.push('--serves', serve.serves.join(','));
7671
+ }
7228
7672
  }
7229
7673
  const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
7230
7674
  child.unref();
@@ -7404,8 +7848,8 @@ async function postAgentStatus(context, agentId, update) {
7404
7848
  * Launch one headless agent process for one ticket, and see it through to its exit.
7405
7849
  *
7406
7850
  * The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
7407
- * the same thing; only the working directory is resolved here, because only this machine knows
7408
- * where the account is checked out.
7851
+ * the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
7852
+ * final grounding step, including creating or choosing a git worktree before it edits.
7409
7853
  */
7410
7854
  function spawnTicketWorker(context, ctx, ticket) {
7411
7855
  const { spawn } = require('child_process');
@@ -7420,18 +7864,61 @@ function spawnTicketWorker(context, ctx, ticket) {
7420
7864
  return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
7421
7865
  }
7422
7866
 
7423
- const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
7867
+ const baseWorkingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
7868
+ const repositoryWork = !((ticket && ticket.requiresRepositoryWork === false) ||
7869
+ (ticket && ticket.briefFacts && ticket.briefFacts.requiresRepositoryWork === false));
7870
+ const targetBranch = repositoryWork ? ticketTargetBranch(ticket, baseWorkingDirectory) : gitBranchNameInDirectory(baseWorkingDirectory);
7871
+ const suggestedWorktree = repositoryWork && baseWorkingDirectory ? ticketWorktreePath(baseWorkingDirectory, ticketId) : null;
7872
+ const workingDirectory = baseWorkingDirectory;
7424
7873
  // The brief is rendered server-side without the local path (the platform cannot know it), so the
7425
- // resolved directory is appended here. When nothing resolved, the server-rendered brief already
7874
+ // resolved local repository hint is appended here. When nothing resolved, the server-rendered brief already
7426
7875
  // tells the worker to find it rather than guess — do not paper over that with a default.
7427
7876
  // The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
7428
7877
  // learn it: `components status` would show it a lane it never chose, and anything it reports about "the
7429
7878
  // staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
7430
7879
  // is the whole class of defect the lane exists to remove.
7431
7880
  const workerWorkspace = 'ticket-' + ticketId;
7432
- const brief = String(ticket.brief || '') + (workingDirectory
7433
- ? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
7434
- : '') +
7881
+ // A branch nobody resolved is OMITTED, never guessed: a worker told the wrong target branch does not
7882
+ // fail, it does the work in the wrong place and reports success.
7883
+ const localWorktreeText = workingDirectory && repositoryWork
7884
+ ? '\n\n## Local repository grounding\n\n' +
7885
+ 'The supervisor resolved the repository account to this local checkout:\n\n`' + workingDirectory + '`\n\n' +
7886
+ (targetBranch
7887
+ ? 'Target git/component branch: `' + targetBranch + '`.\n'
7888
+ : 'No target branch resolved. Read `remits-cli ticket where --ticket ' + ticketId + '` and the\n' +
7889
+ 'repository itself before you decide which branch this work belongs on — do not assume `main`.\n') +
7890
+ 'Suggested per-ticket worktree path: `' + suggestedWorktree + '`.\n\n' +
7891
+ 'Before editing, establish your own isolated git worktree for this ticket and move into it, so the\n' +
7892
+ 'operator\'s launch checkout is not modified underneath them. **You own this decision** — the\n' +
7893
+ 'supervisor is a plain local process and cannot reason about repository state. The command below is\n' +
7894
+ 'a starting point, not a script to follow blindly; it must start from the remote branch tip, not a\n' +
7895
+ 'stale local ref:\n\n' +
7896
+ '```\n' +
7897
+ (targetBranch
7898
+ ? 'git -C ' + shellQuote(workingDirectory) + ' fetch origin ' + shellQuote(targetBranch) + '\n'
7899
+ : 'git -C ' + shellQuote(workingDirectory) + ' fetch origin\n') +
7900
+ 'git -C ' + shellQuote(workingDirectory) + ' worktree add --force ' + shellQuote(suggestedWorktree) +
7901
+ (targetBranch ? ' ' + targetBranch : ' <branch>') + '\n' +
7902
+ 'cd ' + shellQuote(suggestedWorktree) + '\n' +
7903
+ (targetBranch
7904
+ ? 'git pull --ff-only origin ' + shellQuote(targetBranch) + '\n' +
7905
+ 'git rev-list --left-right --count HEAD...origin/' + shellQuote(targetBranch) + ' # must print: 0 0\n'
7906
+ : 'git pull --ff-only origin <branch>\n' +
7907
+ 'git rev-list --left-right --count HEAD...origin/<branch> # must print: 0 0\n') +
7908
+ 'remits-cli components status\n' +
7909
+ '```\n\n' +
7910
+ 'If the pull cannot fast-forward, or either side of that rev-list count is non-zero, stop and settle\n' +
7911
+ 'the branch before editing. A worktree and workspace isolate this worker from the launch checkout and\n' +
7912
+ 'from other staging lanes; they do not prove which commit the files are based on.\n\n' +
7913
+ 'If git still refuses because the branch is already checked out elsewhere, add `--detach`, and in THAT case\n' +
7914
+ 'only, export `REMITS_GIT_BRANCH=' + (targetBranch || '<branch>') + '` so the branch this run reports\n' +
7915
+ '(staging scope, landing lease, run context) matches the branch it is working — git cannot name a\n' +
7916
+ 'branch for a detached worktree, so nothing else can answer. On a normal worktree do not set it and\n' +
7917
+ 'do not need to: git is asked first and the variable is ignored whenever git has a real answer.\n'
7918
+ : (workingDirectory
7919
+ ? '\n\n## Resolved local repository\n\n`' + workingDirectory + '`\n'
7920
+ : '');
7921
+ const brief = String(ticket.brief || '') + localWorktreeText +
7435
7922
  '\n\n## Your staging lane\n\n' +
7436
7923
  'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
7437
7924
  'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
@@ -7446,6 +7933,12 @@ function spawnTicketWorker(context, ctx, ticket) {
7446
7933
  // worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
7447
7934
  // it derived from the repository's edit lease, and that is the authority. The session's own --mode can
7448
7935
  // only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
7936
+ //
7937
+ // `working` is a THIRD phase, and it must fall on the edit side of this branch: it means the account
7938
+ // declared that this ticket touches no repository, so no lease was taken and none was refused. A run
7939
+ // like that is not restricted — it simply has no checkout to be exclusive about. Testing for
7940
+ // `!== 'investigating'` rather than `=== 'editing'` is what keeps a new phase from silently demoting
7941
+ // every non-repository worker to read-only.
7449
7942
  const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
7450
7943
  ? 'investigate'
7451
7944
  : 'edit';
@@ -7498,15 +7991,22 @@ function spawnTicketWorker(context, ctx, ticket) {
7498
7991
  sandbox: built.sandbox || null,
7499
7992
  startedAt: new Date().toISOString(),
7500
7993
  workingDirectory: workingDirectory || null,
7994
+ targetBranch: targetBranch || null,
7995
+ suggestedWorktree: suggestedWorktree || null,
7501
7996
  accountId: ticket.accountId || null,
7502
7997
  subject: ticket.subject || null,
7503
7998
  logFile
7504
7999
  });
7505
8000
  appendGlobalActivityLog('agent', 'worker.started', {
7506
8001
  agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
8002
+ targetBranch: targetBranch || null,
8003
+ suggestedWorktree: suggestedWorktree || null,
7507
8004
  // Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
7508
8005
  // on the record rather than inferable from a sandbox flag nobody logged.
7509
- phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
8006
+ // Report the phase the PLATFORM derived, not the one this launcher collapsed it to: an operator
8007
+ // reading `editing` on a ticket that never involved a repository would go looking for a lease that
8008
+ // was correctly never taken.
8009
+ phase: effectiveMode === 'investigate' ? 'investigating' : (ticket.phase || 'editing'),
7510
8010
  editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
7511
8011
  });
7512
8012
  if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
@@ -7637,6 +8137,7 @@ async function agentHeartbeatDaemonCommand(flags) {
7637
8137
  // worker kind must come from the flag it was started with, never from re-detection.
7638
8138
  workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
7639
8139
  mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
8140
+ serves: parseServedWorkstreams(flags.serves),
7640
8141
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
7641
8142
  runId: 'run-' + Date.now().toString(36)
7642
8143
  };
@@ -7742,6 +8243,31 @@ async function agentHeartbeatDaemonCommand(flags) {
7742
8243
  });
7743
8244
  }
7744
8245
 
8246
+ // A supervisor sitting idle beside a full queue is either broken or doing exactly what it was
8247
+ // told, and those look identical from outside. Logged ONCE per stretch of empty polls, because a
8248
+ // reason repeated every few seconds is a log nobody reads.
8249
+ const sweep = (response && response.sweep) || {};
8250
+ if (emptyPolls === 0) {
8251
+ if ((sweep.needsHumanTicketIds || []).length) {
8252
+ appendGlobalActivityLog('agent', 'serve.needs_human', {
8253
+ agentId,
8254
+ ticketIds: sweep.needsHumanTicketIds,
8255
+ message: sweep.needsHumanTicketIds.length + ' ticket(s) have been through this account\'s ' +
8256
+ 'autonomous-run limit (' + sweep.autonomousRunLimit + ') and are no longer offered to ' +
8257
+ 'workers. Answer, reopen or route one by hand: remits-cli ticket queue --min-runs 1'
8258
+ }, 'warn');
8259
+ }
8260
+ if ((sweep.outOfScopeTicketIds || []).length) {
8261
+ appendGlobalActivityLog('agent', 'serve.out_of_scope', {
8262
+ agentId,
8263
+ ticketIds: sweep.outOfScopeTicketIds,
8264
+ message: sweep.outOfScopeTicketIds.length + ' unrouted ticket(s) are outside the workstreams ' +
8265
+ 'this session serves (' + (sweep.serves || []).join(', ') + '). That is the declaration ' +
8266
+ 'working; another session has to cover them.'
8267
+ });
8268
+ }
8269
+ }
8270
+
7745
8271
  for (const ticket of (response && response.tickets) || []) {
7746
8272
  const ticketId = String(ticket.id || ticket.ticketId || '');
7747
8273
  if (!ticketId || inFlight.has(ticketId)) {
@@ -7772,7 +8298,12 @@ async function agentHeartbeatDaemonCommand(flags) {
7772
8298
  'max-concurrent': String(ctx.maxConcurrent),
7773
8299
  json: 'true'
7774
8300
  },
7775
- { spawnHeartbeat: false }
8301
+ // Re-register with THIS supervisor's declared bound and queues. The platform treats an absent
8302
+ // `mode`/`serves` as "unchanged", which is right for a heartbeat against a live record and wrong
8303
+ // here: this path runs precisely because the record EXPIRED, so there is nothing left to be
8304
+ // unchanged from, and a specialised machine would come back as a generalist that can edit.
8305
+ { spawnHeartbeat: false,
8306
+ serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent } }
7776
8307
  ).catch((err) => {
7777
8308
  appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
7778
8309
  { agentId, error: describeError(err) }, 'error');
@@ -7908,6 +8439,28 @@ async function agentWorkCommand(flags) {
7908
8439
  * record, so the same commands work on every Remits platform — which is what an autonomous worker
7909
8440
  * needs, since its brief has to be written before anyone knows which platform it will run on.
7910
8441
  */
8442
+ /**
8443
+ * Say that this ticket is governed, and say it loudly when the policy could not be parsed.
8444
+ *
8445
+ * A worker that only ever learns a rule exists by being refused by it has already wasted the call that
8446
+ * hit it — the same argument that inlines the rules into a worker brief instead of pointing at them.
8447
+ */
8448
+ function printTicketPolicy(response) {
8449
+ const policy = response && response.policy;
8450
+ if (!policy) return;
8451
+ if (policy.error) {
8452
+ console.error('POLICY NOT ENFORCED: this account has support-ticket rules that could not be parsed: ' +
8453
+ policy.error);
8454
+ console.error(' Report this on the ticket. Do not read the absence of a refusal as permission.');
8455
+ return;
8456
+ }
8457
+ if (policy.ruleCount) {
8458
+ console.log('Policy: ' + policy.ruleCount +
8459
+ (policy.ruleCount === 1 ? ' rule governs' : ' rules govern') +
8460
+ ' this ticket (' + (policy.ruleIds || []).join(', ') + ').');
8461
+ }
8462
+ }
8463
+
7911
8464
  async function ticketCommand(flags, subcommand) {
7912
8465
  const action = String(subcommand || 'read').toLowerCase();
7913
8466
 
@@ -7987,6 +8540,15 @@ async function ticketCommand(flags, subcommand) {
7987
8540
  size: planningFlagValue(flags.size),
7988
8541
  blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7989
8542
  workstream: planningFlagValue(flags.workstream),
8543
+ // Relations and commitments clear the same way a planning slot does — the flag with no value — so
8544
+ // `--caused-by` alone detaches a ticket from its cluster rather than being read as "no change".
8545
+ duplicateOf: planningFlagValue(firstDefined(flags['duplicate-of'], flags.duplicateOf)),
8546
+ causedBy: planningFlagValue(firstDefined(flags['caused-by'], flags.causedBy)),
8547
+ dueAt: planningFlagValue(firstDefined(flags['due-at'], flags.due, flags.dueAt)),
8548
+ respondBy: planningFlagValue(firstDefined(flags['respond-by'], flags.respondBy)),
8549
+ // Tri-state on the wire: 'true'/'false' are the two answers and absent is "not saying", which the
8550
+ // platform reads as "keeps today's behaviour". Coercing here would erase the third state.
8551
+ requiresRepositoryWork: firstDefined(flags['repo-work'], flags['requires-repository-work']),
7990
8552
  tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7991
8553
  label: flags.label,
7992
8554
  url: flags.url,
@@ -8013,6 +8575,11 @@ async function ticketCommand(flags, subcommand) {
8013
8575
  agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
8014
8576
  runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
8015
8577
  agentLabel: flags['agent-label'] || undefined,
8578
+ // The acknowledgement that gets a caller past a `require` rule. It is deliberately NOT enough on its
8579
+ // own: the platform honours it only for an actor the rule permits, and it decides "human" from the
8580
+ // ABSENCE of a run identity — which a worker process always sends. So a headless worker passing this
8581
+ // is refused, and told so. The override, when it is honoured, is written to the ticket worklog.
8582
+ acknowledge: flags.acknowledge || undefined,
8016
8583
  // WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
8017
8584
  // process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
8018
8585
  // (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
@@ -8045,6 +8612,8 @@ async function ticketCommand(flags, subcommand) {
8045
8612
  return;
8046
8613
  }
8047
8614
 
8615
+ printTicketPolicy(response);
8616
+
8048
8617
  if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
8049
8618
  console.log(JSON.stringify(response, null, 2));
8050
8619
  return;
@@ -8161,6 +8730,20 @@ async function ticketQueueCommand(flags) {
8161
8730
  boardStage: flags['board-stage'] || flags.boardStage,
8162
8731
  blockedBy: flags['blocked-by'] || flags.blockedBy,
8163
8732
  search: flags.search || flags.q,
8733
+ // Every one of these reads a field the platform already mirrored and already projected, and that
8734
+ // no surface could ask about. `syncState` in particular is how a broken integration is found:
8735
+ // a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
8736
+ syncStates: csv(flags['sync-state'] || flags.syncState),
8737
+ overdue: flagEnabled(flags.overdue) || undefined,
8738
+ relatedTo: flags['related-to'] || flags.relatedTo,
8739
+ duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
8740
+ causedBy: flags['caused-by'] || flags.causedBy,
8741
+ hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
8742
+ dueBefore: flags['due-before'] || flags.dueBefore,
8743
+ dueAfter: flags['due-after'] || flags.dueAfter,
8744
+ // "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
8745
+ // mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
8746
+ minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
8164
8747
  sortBy: flags['sort-by'] || flags.sortBy,
8165
8748
  sortDirection: flags['sort-direction'] || flags.sortDirection,
8166
8749
  includeChildren: flags['include-children'] === 'false' ? false : undefined,
@@ -8181,7 +8764,15 @@ async function ticketQueueCommand(flags) {
8181
8764
  console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
8182
8765
  ' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
8183
8766
  ', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
8767
+ ((counts.needsHuman || 0) ? ', ' + counts.needsHuman + ' NEEDS A PERSON' : '') +
8184
8768
  ' [' + (response.dataMode || '?') + ' lane]');
8769
+ if (counts.needsHuman) {
8770
+ // The one count that measures a backlog the automation deliberately created. Without it the fleet
8771
+ // reports healthy activity while the queue quietly stops going down.
8772
+ console.log(' ' + counts.needsHuman + ' ticket(s) have been through this account\'s autonomous-run limit ' +
8773
+ 'and are no longer offered to workers.');
8774
+ console.log(' Find them: remits-cli ticket queue --min-runs 1 --unassigned (then answer, reopen, or route one by hand)');
8775
+ }
8185
8776
  console.log('');
8186
8777
  // Board position is printed only when some ticket in this queue actually has one. An always-on column
8187
8778
  // would be blank for every account that does not run a board, and a blank column reads as missing data.
@@ -8199,6 +8790,7 @@ async function ticketQueueCommand(flags) {
8199
8790
  // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
8200
8791
  const marks = [
8201
8792
  t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
8793
+ t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
8202
8794
  showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
8203
8795
  showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
8204
8796
  showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
@@ -8367,6 +8959,19 @@ function printTicketHelp() {
8367
8959
  console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
8368
8960
  console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
8369
8961
  console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
8962
+ console.log(' --overdue lists tickets past a due time their account committed to; --sort-by due');
8963
+ console.log(' ranks by it (soonest first; undated sorts last, in BOTH directions).');
8964
+ console.log(' --sync-state failed finds tickets that cannot reach the system this account syncs');
8965
+ console.log(' with. A failed sync retries forever, so it is indistinguishable from a healthy');
8966
+ console.log(' pending one in `pendingSync` — this is how a broken integration is found before the');
8967
+ console.log(' partner reports it.');
8968
+ console.log(' --related-to ID lists the cluster around one ticket; --has-cause lists every ticket');
8969
+ console.log(' that defers to another one.');
8970
+ console.log(' --min-runs N finds tickets autonomous workers have already picked up N times and');
8971
+ console.log(' handed back. Past this account\'s limit the sweep stops offering them, so they are a');
8972
+ console.log(' backlog the automation deliberately created and nothing else surfaces: answer one');
8973
+ console.log(' (`ticket answer`), reopen it, or route it by hand — an explicit route is never');
8974
+ console.log(' vetoed by the limit.');
8370
8975
  console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
8371
8976
  console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
8372
8977
  console.log(' rather than widening the ticket you were given. [--priority --description --tags');
@@ -8407,8 +9012,9 @@ function printTicketHelp() {
8407
9012
  console.log(' reopen | assign --assignee EMAIL');
8408
9013
  console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
8409
9014
  console.log(' unroute Clear its routing, e.g. when that session is gone.');
8410
- console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
8411
- console.log(' near-identical tickets visible as one cluster to whoever looks next.');
9015
+ console.log(' tag --tags a,b Mark it, for a HUMAN reading the queue. When the');
9016
+ console.log(' tickets genuinely share a cause, use `relate` instead or as well: the platform reads');
9017
+ console.log(' that one, and a tag it cannot read will not stop a cluster being worked N times.');
8412
9018
  console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
8413
9019
  console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
8414
9020
  console.log(' worklog summary, where it is neither typed nor retrievable.');
@@ -8420,6 +9026,20 @@ function printTicketHelp() {
8420
9026
  console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
8421
9027
  console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
8422
9028
  console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
9029
+ console.log(' relate [--caused-by ID | --duplicate-of ID] [--reason "..."]');
9030
+ console.log(' Say this ticket is part of a CLUSTER: N tickets, N requesters, one root cause.');
9031
+ console.log(' Unlike a tag, the platform READS it — a ticket whose cause is being worked is not');
9032
+ console.log(' handed out again while that work runs, so a cluster costs one worker and not N.');
9033
+ console.log(' --duplicate-of: no separate work at all. --caused-by: this requester still needs');
9034
+ console.log(' their own outcome once the cause is settled. A flag with no value clears it.');
9035
+ console.log(' due --due-at 2026-09-08T17:00:00Z [--respond-by ...]');
9036
+ console.log(' Record the times YOUR organization committed to. The platform stores, sorts and');
9037
+ console.log(' filters these and never acts on them: no timer, no escalation. Build those as an');
9038
+ console.log(' Action sweeping `ticket queue --overdue`.');
9039
+ console.log(' repo-work --repo-work false');
9040
+ console.log(' Declare that this ticket does not involve the repository — a documented answer, or');
9041
+ console.log(' something only a person can do. A worker then takes no repository edit lease for it');
9042
+ console.log(' and blocks nobody who needs the checkout. Absent means it DOES; nothing is inferred.');
8423
9043
  console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
8424
9044
  console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
8425
9045
  console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
@@ -8458,6 +9078,23 @@ function runContextPayload(flags = {}) {
8458
9078
  * and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
8459
9079
  * each other's staged components.
8460
9080
  */
9081
+ /**
9082
+ * What a session was launched to do: the workstreams it covers and whether it may edit.
9083
+ *
9084
+ * Returns null for an unrestricted, edit-capable session — the default — so the line only ever appears
9085
+ * when there IS a restriction to know about. A line that renders for everybody is a line nobody reads.
9086
+ */
9087
+ function describeAgentScope(record) {
9088
+ if (!record) return null;
9089
+ const serves = Array.isArray(record.serves) ? record.serves.filter(Boolean) : [];
9090
+ const readOnly = record.mode === 'investigate' || record.canEdit === false;
9091
+ if (!serves.length && !readOnly) return null;
9092
+ const parts = [];
9093
+ parts.push(serves.length ? 'serves ' + serves.join(', ') : 'serves every workstream');
9094
+ parts.push(readOnly ? 'investigate only (never edits or stages)' : 'may edit');
9095
+ return parts.join(' · ');
9096
+ }
9097
+
8461
9098
  function describeRunLocation(record) {
8462
9099
  if (!record) return '';
8463
9100
  const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
@@ -8533,6 +9170,11 @@ async function agentWorkMapCommand(flags) {
8533
9170
  (agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
8534
9171
  const at = describeRunLocation(agent);
8535
9172
  if (at) console.log(' ' + at);
9173
+ // What this session is FOR, beside where it is. An operator reading a map of idle machines beside a
9174
+ // full queue needs to see that they are idle BY DECLARATION — read-only, or serving other
9175
+ // workstreams — not that routing is broken.
9176
+ const bound = describeAgentScope(agent);
9177
+ if (bound) console.log(' ' + bound);
8536
9178
  if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
8537
9179
  }
8538
9180
  console.log('');
@@ -8590,6 +9232,7 @@ async function agentListCommand(flags) {
8590
9232
  console.log(' lane ' + (agent.dataMode || 'prod') +
8591
9233
  ' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
8592
9234
  (agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
9235
+ console.log(' serves ' + (describeAgentScope(agent) || 'every workstream, and may edit'));
8593
9236
  if (agent.activity) console.log(' doing ' + agent.activity);
8594
9237
  // Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
8595
9238
  // `cwd`: a record written by an older platform, or still in Redis from before this field was
@@ -8715,6 +9358,7 @@ async function agentReleaseCommand(flags) {
8715
9358
  if (daemonPid) {
8716
9359
  workersStopped = listWorkers(agentId).length;
8717
9360
  try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
9361
+ await waitForProcessExit(daemonPid, 5000);
8718
9362
  }
8719
9363
 
8720
9364
  const context = agentSessionFor(flags, local);
@@ -8750,6 +9394,7 @@ function printAgentHelp() {
8750
9394
  console.log('Usage: remits-cli agent <subcommand>');
8751
9395
  console.log('');
8752
9396
  console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
9397
+ console.log(' [--serves support,incident,sdlc]');
8753
9398
  console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
8754
9399
  console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
8755
9400
  console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
@@ -8760,10 +9405,17 @@ function printAgentHelp() {
8760
9405
  console.log(' session is running (when you start it from inside one), else your');
8761
9406
  console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
8762
9407
  console.log(' --mode investigate runs workers read-only (no file edits).');
9408
+ console.log(' --serves declares which WORKSTREAMS this machine covers, so a fleet can be a set of');
9409
+ console.log(' queues rather than interchangeable machines: --serves sdlc on one box, --serves');
9410
+ console.log(' support,incident on another. Only those tickets are routed here and only those are');
9411
+ console.log(' swept. Omit it to serve everything, which is the default. A workstream no live');
9412
+ console.log(' session covers goes unrouted and is reported as no_agent_serves_workstream — the');
9413
+ console.log(' declaration is honoured rather than quietly overridden, so check `agent map` after');
9414
+ console.log(' specialising a fleet.');
8763
9415
  console.log('');
8764
9416
  console.log(' workers [--json] What this session is running right now.');
8765
9417
  console.log('');
8766
- console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
9418
+ console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
8767
9419
  console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
8768
9420
  console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
8769
9421
  console.log(' directory — it registers for every account repo indexed on this machine that your');
@@ -9443,8 +10095,13 @@ async function main() {
9443
10095
  console.log(' - only which staged entries shadow them changes');
9444
10096
  console.log('');
9445
10097
  console.log('Typical setup, one worktree per agent, all on the same branch:');
9446
- console.log(' git worktree add ../ms-agent-a forked');
10098
+ console.log(' git fetch origin');
10099
+ console.log(' git switch forked');
10100
+ console.log(' git pull --ff-only origin forked');
10101
+ console.log(' git rev-list --left-right --count HEAD...origin/forked # must print: 0 0');
10102
+ console.log(' git worktree add --force ../ms-agent-a forked');
9447
10103
  console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
10104
+ console.log(' remits-cli components status # branch world + staging lane + other lanes');
9448
10105
  console.log(' remits-cli components stage # isolated lane; nobody else sees it');
9449
10106
  console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
9450
10107
  console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
@@ -9624,6 +10281,13 @@ async function main() {
9624
10281
  }
9625
10282
 
9626
10283
  main().catch((err) => {
10284
+ // A REFUSAL is an answer, not a fault. The platform's sentence already says exactly what is wrong and
10285
+ // what to do instead; wrapping it in "remits-cli error:" and a stack invites the caller to retry the
10286
+ // identical call, which is the one thing a refused caller must not do.
10287
+ if (err && err.refused) {
10288
+ console.error('Refused: ' + err.message);
10289
+ process.exit(1);
10290
+ }
9627
10291
  if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
9628
10292
  console.error(err.stack);
9629
10293
  }