@remits/remits-cli 0.1.145 → 0.1.148

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
@@ -4,18 +4,18 @@
4
4
  ## Table of Contents
5
5
 
6
6
  - L22 Runtime Bootstrap And Shared State
7
- - L123 Sessions, Account Resolution, And Production Guards
8
- - L615 Local State, Workspaces, And Verification Evidence
9
- - L2652 Account Repos, Guide Sync, And Platform Repo
10
- - L3460 Component Discovery And HTTP Logging
11
- - L4107 Skill Delivery And TOC Resolution
12
- - L4417 Auth And Component Staging
13
- - L5032 Component Summaries, Status, And Sync Gates
14
- - L7332 Branches, Promotion, Commit, And Test Runs
15
- - L9421 Tokens, Tools, Verification, And Config
16
- - L10848 Service Dashboard And WebSocket Listener
17
- - L13313 Agent And Ticket Workflows
18
- - L16908 Help, Auto Update, And Command Dispatch
7
+ - L124 Sessions, Account Resolution, And Production Guards
8
+ - L621 Local State, Workspaces, And Verification Evidence
9
+ - L2659 Account Repos, Guide Sync, And Platform Repo
10
+ - L3499 Component Discovery And HTTP Logging
11
+ - L4225 Skill Delivery And TOC Resolution
12
+ - L4540 Auth And Component Staging
13
+ - L5172 Component Summaries, Status, And Sync Gates
14
+ - L7473 Branches, Promotion, Commit, And Test Runs
15
+ - L9562 Tokens, Tools, Verification, And Config
16
+ - L10989 Service Dashboard And WebSocket Listener
17
+ - L13467 Agent And Ticket Workflows
18
+ - L17602 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -49,6 +49,7 @@ const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
49
49
  const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
50
50
  const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
51
51
  const ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'activity.log');
52
+ const GUIDES_CACHE_FILE = path.join(SESSION_DIR, 'guides-cache.json');
52
53
  const SKILL_SOURCE_DIR = path.join(__dirname, 'skills', 'remits-cli');
53
54
  const SKILL_SOURCE_FILE = path.join(SKILL_SOURCE_DIR, 'SKILL.md');
54
55
  const SKILL_REFERENCES_DIRNAME = 'references';
@@ -154,6 +155,11 @@ function parseArgs(argv) {
154
155
  return out;
155
156
  }
156
157
 
158
+ // A flag's text, or '' — a bare `--role` with no value parses as `true`, which is not a value.
159
+ function textFlag(value) {
160
+ return typeof value === 'string' ? value.trim() : '';
161
+ }
162
+
157
163
  function flagEnabled(value) {
158
164
  return value === true || value === 'true' || value === '1' || value === 'yes';
159
165
  }
@@ -634,6 +640,7 @@ function localStatePaths(cwd) {
634
640
  sharedDir: path.join(base, 'shared'),
635
641
  toolsDir: path.join(base, 'shared', 'tools'),
636
642
  workstreamsDir: path.join(base, 'shared', 'workstreams'),
643
+ componentStageCacheFile: path.join(base, 'shared', 'component-stage-cache.json'),
637
644
  sessionsDir: path.join(actorDir, 'sessions'),
638
645
  toolResponsesDir: path.join(actorDir, 'tool-responses'),
639
646
  verificationDir: path.join(actorDir, 'verification'),
@@ -2805,6 +2812,31 @@ function writeGuidePayloadIntoRepo(repoDir, payload) {
2805
2812
  // invocation that touches multiple repos only fetches guides once.
2806
2813
  let GUIDES_PAYLOAD_CACHE = null;
2807
2814
 
2815
+ function readGuidesPayloadCache(cacheKey) {
2816
+ try {
2817
+ if (!fs.existsSync(GUIDES_CACHE_FILE)) return null;
2818
+ const cached = JSON.parse(fs.readFileSync(GUIDES_CACHE_FILE, 'utf8'));
2819
+ if (!cached || cached.key !== cacheKey || !cached.payload || !cached.guideHash) return null;
2820
+ return cached;
2821
+ } catch (_) {
2822
+ return null;
2823
+ }
2824
+ }
2825
+
2826
+ function writeGuidesPayloadCache(cacheKey, payload) {
2827
+ if (!payload || !payload.guideHash) return;
2828
+ try {
2829
+ atomicWriteFile(GUIDES_CACHE_FILE, JSON.stringify({
2830
+ key: cacheKey,
2831
+ guideHash: payload.guideHash,
2832
+ payload,
2833
+ updatedAt: new Date().toISOString()
2834
+ }, null, 2));
2835
+ } catch (_) {
2836
+ // Best-effort: the in-process cache still covers this invocation.
2837
+ }
2838
+ }
2839
+
2808
2840
  async function fetchGuidesPayload(cwd, ctx) {
2809
2841
  const baseUrl = normalizeBaseUrl((ctx && ctx.baseUrl) || DEFAULT_BASE_URL);
2810
2842
  const token = ctx && ctx.session && ctx.session.token;
@@ -2815,21 +2847,28 @@ async function fetchGuidesPayload(cwd, ctx) {
2815
2847
  if (GUIDES_PAYLOAD_CACHE && GUIDES_PAYLOAD_CACHE.key === cacheKey) {
2816
2848
  return GUIDES_PAYLOAD_CACHE.payload;
2817
2849
  }
2850
+ const diskCache = readGuidesPayloadCache(cacheKey);
2818
2851
  const api = buildAxios(baseUrl, token);
2819
2852
  let data = null;
2820
2853
  try {
2821
2854
  data = await loggedPost(api, cwd, '/cli/guides', {
2822
2855
  token,
2823
- accountId: (ctx && ctx.accountId) || undefined
2856
+ accountId: (ctx && ctx.accountId) || undefined,
2857
+ guideHash: diskCache && diskCache.guideHash ? diskCache.guideHash : undefined
2824
2858
  }).then((r) => r.data);
2825
2859
  } catch (_) {
2826
2860
  // Best-effort: never fail a command because guides could not be fetched.
2827
2861
  return null;
2828
2862
  }
2863
+ if (data && data.notModified && diskCache && diskCache.payload) {
2864
+ GUIDES_PAYLOAD_CACHE = { key: cacheKey, payload: diskCache.payload };
2865
+ return diskCache.payload;
2866
+ }
2829
2867
  if (!data || !data.success) {
2830
2868
  return null;
2831
2869
  }
2832
2870
  GUIDES_PAYLOAD_CACHE = { key: cacheKey, payload: data };
2871
+ writeGuidesPayloadCache(cacheKey, data);
2833
2872
  return data;
2834
2873
  }
2835
2874
 
@@ -3672,6 +3711,79 @@ function collectComponents(cwd) {
3672
3711
  return canonicalized.components;
3673
3712
  }
3674
3713
 
3714
+ const STAGE_CONTENT_FIELDS = ['source', 'prompt', 'html', 'javascript', 'schema', 'inputSchema', 'previewData', 'messages'];
3715
+
3716
+ function componentStageCacheKey(baseUrl, accountId, branchName, workspace, dataMode) {
3717
+ return [normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL), accountId || '', branchName || '', workspace || '', normalizeDataMode(dataMode)].join('|');
3718
+ }
3719
+
3720
+ function readComponentStageCache(cwd) {
3721
+ const file = localStatePaths(cwd).componentStageCacheFile;
3722
+ try {
3723
+ if (!fs.existsSync(file)) return {};
3724
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
3725
+ return parsed && typeof parsed === 'object' ? parsed : {};
3726
+ } catch (_) {
3727
+ return {};
3728
+ }
3729
+ }
3730
+
3731
+ function writeComponentStageCache(cwd, cache) {
3732
+ try {
3733
+ atomicWriteFile(localStatePaths(cwd).componentStageCacheFile, JSON.stringify(cache || {}, null, 2));
3734
+ } catch (_) {
3735
+ // Best-effort. A missed cache only means the next stage sends full content.
3736
+ }
3737
+ }
3738
+
3739
+ function slimComponentsForStage(cwd, components, world) {
3740
+ const cache = readComponentStageCache(cwd);
3741
+ const worldKey = componentStageCacheKey(world.baseUrl, world.accountId, world.branchName, world.workspace, world.dataMode);
3742
+ const worldCache = cache[worldKey] && typeof cache[worldKey] === 'object' ? cache[worldKey] : {};
3743
+ let omittedComponents = 0;
3744
+ let omittedFields = 0;
3745
+ let omittedBytes = 0;
3746
+ const slim = (components || []).map((component) => {
3747
+ const identity = stageIdentityKey(component);
3748
+ const cached = worldCache[identity];
3749
+ if (!cached || cached.hash !== component.hash) {
3750
+ return component;
3751
+ }
3752
+ const omitted = [];
3753
+ const next = Object.assign({}, component);
3754
+ for (const field of STAGE_CONTENT_FIELDS) {
3755
+ if (typeof next[field] === 'string') {
3756
+ omitted.push(field);
3757
+ omittedBytes += Buffer.byteLength(next[field], 'utf8');
3758
+ delete next[field];
3759
+ }
3760
+ }
3761
+ if (omitted.length) {
3762
+ next.omittedContentFields = omitted;
3763
+ omittedComponents += 1;
3764
+ omittedFields += omitted.length;
3765
+ return next;
3766
+ }
3767
+ return component;
3768
+ });
3769
+ return { components: slim, stats: { omittedComponents, omittedFields, omittedBytes } };
3770
+ }
3771
+
3772
+ function rememberSuccessfulComponentStage(cwd, components, world) {
3773
+ const cache = readComponentStageCache(cwd);
3774
+ const worldKey = componentStageCacheKey(world.baseUrl, world.accountId, world.branchName, world.workspace, world.dataMode);
3775
+ const worldCache = {};
3776
+ for (const component of components || []) {
3777
+ if (!component || !component.hash) continue;
3778
+ worldCache[stageIdentityKey(component)] = {
3779
+ hash: component.hash,
3780
+ updatedAt: new Date().toISOString()
3781
+ };
3782
+ }
3783
+ cache[worldKey] = worldCache;
3784
+ writeComponentStageCache(cwd, cache);
3785
+ }
3786
+
3675
3787
  function componentPathInfo(cwd, filePath) {
3676
3788
  const mapping = {
3677
3789
  schemas: 'schema',
@@ -3868,6 +3980,12 @@ function buildAxios(baseUrl, token, timeoutMs = 60000) {
3868
3980
  // diagnostics it is missing exist, and every output improvement lands invisibly.
3869
3981
  const cliVersion = readCliVersion();
3870
3982
  if (cliVersion) headers['X-Remits-Cli-Version'] = cliVersion;
3983
+ // A worker's run identity rides on every call, so a verb whose payload does not repeat it (stage, commit,
3984
+ // a tool) is still attributed to the run. The ROLE is deliberately not sent: the platform reads it from
3985
+ // the session's registration, where the user's permission to declare it was checked.
3986
+ if (process.env.REMITS_AGENT_ID) headers['X-Remits-Agent-Id'] = process.env.REMITS_AGENT_ID;
3987
+ if (process.env.REMITS_WORKER_RUN_ID) headers['X-Remits-Run-Id'] = process.env.REMITS_WORKER_RUN_ID;
3988
+ if (process.env.REMITS_SUPPORT_TICKET_ID) headers['X-Remits-Ticket-Id'] = process.env.REMITS_SUPPORT_TICKET_ID;
3871
3989
  return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
3872
3990
  }
3873
3991
 
@@ -4274,16 +4392,21 @@ function resolveSkillTargets(target) {
4274
4392
  gemini: {
4275
4393
  skillPath: path.join(home, '.gemini', 'skills', 'remits-cli.md'),
4276
4394
  referencesDir: path.join(home, '.gemini', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
4395
+ },
4396
+ antigravity: {
4397
+ skillPath: path.join(home, '.gemini', 'config', 'skills', 'remits-cli', 'SKILL.md'),
4398
+ referencesDir: path.join(home, '.gemini', 'config', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
4277
4399
  }
4278
4400
  };
4279
4401
 
4280
4402
  if (value === 'all') {
4281
4403
  return Object.entries(targets);
4282
4404
  }
4283
- if (!targets[value]) {
4284
- throw new Error('Unknown --target value: ' + target + ' (expected codex|claude|gemini|all)');
4405
+ const resolved = value === 'agy' ? 'antigravity' : value;
4406
+ if (!targets[resolved]) {
4407
+ throw new Error('Unknown --target value: ' + target + ' (expected codex|claude|gemini|antigravity|all)');
4285
4408
  }
4286
- return [[value, targets[value]]];
4409
+ return [[resolved, targets[resolved]]];
4287
4410
  }
4288
4411
 
4289
4412
  function listSkillReferenceSources() {
@@ -4737,6 +4860,7 @@ async function pushComponentsCommand(flags) {
4737
4860
  packetType: 'stage',
4738
4861
  world: { repoAccountId: accountId, accountId, executionAccountId: accountId, dataMode, branchName, workspace }
4739
4862
  }, { command: 'components stage' });
4863
+ const slimStage = slimComponentsForStage(cwd, components, { baseUrl, accountId, branchName, workspace, dataMode });
4740
4864
  const stagePayload = {
4741
4865
  token: session.token,
4742
4866
  accountId,
@@ -4768,13 +4892,20 @@ async function pushComponentsCommand(flags) {
4768
4892
  baseSha: safeGitValue(cwd, 'git rev-parse HEAD') || undefined,
4769
4893
  // Kept for a platform that predates stageMode. Same meaning it always had.
4770
4894
  replace: !changedOnly,
4771
- components
4895
+ components: slimStage.components,
4896
+ omittedContentStats: slimStage.stats
4772
4897
  };
4773
4898
 
4774
4899
  let response;
4775
4900
  try {
4776
4901
  response = await stageOrRefuse(api, cwd, stagePayload);
4777
4902
  } catch (err) {
4903
+ if (err && err.responseBody && err.responseBody.code === 'missing_omitted_content' &&
4904
+ stagePayload.components !== components) {
4905
+ stagePayload.components = components;
4906
+ stagePayload.omittedContentStats = Object.assign({}, slimStage.stats, { retriedFull: true });
4907
+ response = await stageOrRefuse(api, cwd, stagePayload);
4908
+ } else {
4778
4909
  // A REFUSED stage is evidence too. Recording it only on the success path is how a lane ends up with
4779
4910
  // an envelope that says nothing happened between two packets, when in fact a stage was attempted and
4780
4911
  // the platform rejected it. The packet is best-effort: a failing stage must surface the STAGE error,
@@ -4803,6 +4934,7 @@ async function pushComponentsCommand(flags) {
4803
4934
  console.error('Could not record the refused-stage verification packet: ' + packetError.message);
4804
4935
  }
4805
4936
  throw err;
4937
+ }
4806
4938
  }
4807
4939
  response.changedFromWorkingTree = changedFromWorkingTree || [];
4808
4940
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
@@ -4810,12 +4942,14 @@ async function pushComponentsCommand(flags) {
4810
4942
  response.unrepresentableChanges = unstageable;
4811
4943
  response.componentDiscoveryWarnings = componentDiscoveryWarnings;
4812
4944
  response.localState = localStateSummary(cwd, flags);
4945
+ response.omittedContentStats = stagePayload.omittedContentStats || slimStage.stats;
4813
4946
  if (response.success === false) {
4814
4947
  // A 200 that says `success:false`. The 422 path throws out of stageOrRefuse above and never lands
4815
4948
  // here, so this stays as the belt-and-braces case — print whatever detail came with it first.
4816
4949
  printCompileValidation(response.compileValidation, { stderr: true });
4817
4950
  throw new Error(response.message || 'Server stage failed');
4818
4951
  }
4952
+ rememberSuccessfulComponentStage(cwd, components, { baseUrl, accountId, branchName, workspace, dataMode });
4819
4953
 
4820
4954
  await appendVerificationPacket(api, cwd, session, accountId, verification.evidenceFlags, {
4821
4955
  type: 'stage',
@@ -4993,6 +5127,12 @@ async function stageOrRefuse(api, cwd, payload) {
4993
5127
  } catch (err) {
4994
5128
  const body = err && err.response && err.response.data;
4995
5129
  const status = err && err.response && err.response.status;
5130
+ if (status === 409 && body && body.code === 'missing_omitted_content') {
5131
+ const retryable = new Error(body.message || 'Server needs full component bodies for this staging lane.');
5132
+ retryable.responseBody = body;
5133
+ retryable.status = status;
5134
+ throw retryable;
5135
+ }
4996
5136
  if (status === 409 && body && body.message) {
4997
5137
  if (body.editLease) {
4998
5138
  console.error('Edit lease: ' + describeRunLocation(body.editLease));
@@ -6239,7 +6379,8 @@ async function syncComponentsCommand(rawFlags) {
6239
6379
  const dataMode = resolveDataMode(flags, session);
6240
6380
  const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
6241
6381
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
6242
- const api = buildAxios(baseUrl, session.token);
6382
+ const timeoutMs = parsePositiveInt(flags['timeout-ms'] || flags.timeoutMs, 60000);
6383
+ const api = buildAxios(baseUrl, session.token, timeoutMs);
6243
6384
  // Aligned for the same reason a stage is: the server's plan names a `new_` component by its sidecar
6244
6385
  // `name:`, and the changed set would otherwise name it by its filename and the gate would refuse it.
6245
6386
  const changedFromWorkingTree = alignChangedSetWithComponents(changedComponentsFromWorkingTree(cwd), collectComponentsForIdentity(cwd));
@@ -11669,6 +11810,54 @@ function compactSupportTicketRow(row) {
11669
11810
  };
11670
11811
  }
11671
11812
 
11813
+ function supportTicketQueueScopeSet(queue) {
11814
+ return new Set((queue && queue.searchedAccounts || [])
11815
+ .map((entry) => String(entry.accountId || entry.accountScope || ''))
11816
+ .filter(Boolean));
11817
+ }
11818
+
11819
+ function independentSupportTicketQueues(queues) {
11820
+ const scoped = (queues || []).map((queue) => supportTicketQueueScopeSet(queue));
11821
+ return (queues || []).filter((queue, i) => {
11822
+ if (!scoped[i].size) {
11823
+ return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
11824
+ }
11825
+ return !(queues || []).some((other, j) => {
11826
+ if (i === j || !scoped[j].size) return false;
11827
+ const contained = [...scoped[i]].every((id) => scoped[j].has(id));
11828
+ if (!contained) return false;
11829
+ // Identical scopes: keep the earlier one, drop the later, or both would drop each other.
11830
+ const identical = scoped[i].size === scoped[j].size;
11831
+ return identical ? j < i : true;
11832
+ });
11833
+ });
11834
+ }
11835
+
11836
+ function supportTicketCompartmentGroups(queues) {
11837
+ const byCompartment = {};
11838
+ for (const queue of queues || []) {
11839
+ const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
11840
+ if (!byCompartment[key]) {
11841
+ byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
11842
+ }
11843
+ byCompartment[key].queues.push(queue);
11844
+ }
11845
+
11846
+ return Object.values(byCompartment).map((group) => {
11847
+ const independent = independentSupportTicketQueues(group.queues);
11848
+ const ticketsById = {};
11849
+ for (const queue of independent) {
11850
+ for (const ticket of queue.tickets || []) {
11851
+ const id = String(ticket.ticketId || ticket.id || '');
11852
+ if (!id || ticketsById[id]) continue;
11853
+ ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
11854
+ dataMode: queue.dataMode, compartment: group.compartment };
11855
+ }
11856
+ }
11857
+ return { ...group, independent, tickets: Object.values(ticketsById) };
11858
+ });
11859
+ }
11860
+
11672
11861
  /**
11673
11862
  * The support-ticket queue for one authenticated session.
11674
11863
  *
@@ -11853,44 +12042,9 @@ async function collectAgentSnapshot() {
11853
12042
  * </ul>
11854
12043
  */
11855
12044
  function buildTicketCompartments(queues) {
11856
- const byCompartment = {};
11857
- for (const queue of queues || []) {
11858
- const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
11859
- if (!byCompartment[key]) {
11860
- byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
11861
- }
11862
- byCompartment[key].queues.push(queue);
11863
- }
11864
-
11865
- return Object.values(byCompartment).map((group) => {
11866
- // Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
11867
- // in that one, so keeping it would count them twice. Equal sets keep the first seen.
11868
- const scanned = group.queues.map((queue) => new Set(
11869
- (queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
11870
- const independent = group.queues.filter((queue, i) => {
11871
- if (!scanned[i].size) {
11872
- return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
11873
- }
11874
- return !group.queues.some((other, j) => {
11875
- if (i === j || !scanned[j].size) return false;
11876
- const contained = [...scanned[i]].every((id) => scanned[j].has(id));
11877
- if (!contained) return false;
11878
- // Identical scopes: keep the earlier one, drop the later, or both would drop each other.
11879
- const identical = scanned[i].size === scanned[j].size;
11880
- return identical ? j < i : true;
11881
- });
11882
- });
11883
-
11884
- const ticketsById = {};
11885
- for (const queue of independent) {
11886
- for (const ticket of queue.tickets || []) {
11887
- const id = String(ticket.ticketId || '');
11888
- if (!id || ticketsById[id]) continue;
11889
- ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
11890
- dataMode: queue.dataMode, compartment: group.compartment };
11891
- }
11892
- }
11893
- const tickets = Object.values(ticketsById);
12045
+ return supportTicketCompartmentGroups(queues).map((group) => {
12046
+ const independent = group.independent;
12047
+ const tickets = group.tickets;
11894
12048
 
11895
12049
  const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
11896
12050
  const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
@@ -12855,7 +13009,7 @@ async function handleCliCloneRequest(client, baseUrl, topic, accountIds, userIds
12855
13009
  console.warn('[cli_clone_request] Guide sync failed:', gErr.message);
12856
13010
  }
12857
13011
 
12858
- // 4. Install / refresh agent skills for Claude, Codex, Gemini
13012
+ // 4. Install / refresh agent skills for Claude, Codex, Gemini, Antigravity
12859
13013
  try {
12860
13014
  syncInstalledSkills('all');
12861
13015
  } catch (_) {}
@@ -14056,6 +14210,8 @@ async function agentRegisterCommand(flags, options = {}) {
14056
14210
  'the pid whose exit should end this agent session.');
14057
14211
  }
14058
14212
  const explicitId = (flags['agent-id'] || flags.agentId || process.env.REMITS_AGENT_ID || '').trim();
14213
+ const explicitRole = textFlag(flags.role) || textFlag(options.serve && options.serve.role) || textFlag(process.env.REMITS_AGENT_ROLE);
14214
+ const explicitPersona = textFlag(flags.persona) || textFlag(options.serve && options.serve.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
14059
14215
  // Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
14060
14216
  // to let an unrelated record be adopted whenever two registrations shared an anchor.
14061
14217
  const existingId = explicitId ||
@@ -14066,6 +14222,8 @@ async function agentRegisterCommand(flags, options = {}) {
14066
14222
  const api = buildAxios(baseUrl, session.token, 15000);
14067
14223
  const payload = {
14068
14224
  agentId: existingId || undefined,
14225
+ role: explicitRole || explicitPersona || undefined,
14226
+ persona: explicitPersona || undefined,
14069
14227
  label: flags.label || agentLabelDefault(anchor, cwd),
14070
14228
  agentType: flags.agent || getPreferredAgent(),
14071
14229
  cliVersion: readCliVersion(),
@@ -14110,6 +14268,8 @@ async function agentRegisterCommand(flags, options = {}) {
14110
14268
  cwd,
14111
14269
  baseUrl,
14112
14270
  dataMode,
14271
+ role: response.role || payload.role || null,
14272
+ persona: response.persona || payload.persona || null,
14113
14273
  // The accounts the platform ACCEPTED, so `agent list` can scope itself to the work this session
14114
14274
  // can actually be routed rather than to whichever session happened to be most recent.
14115
14275
  accountIds: response.accountIds || [],
@@ -14133,6 +14293,8 @@ async function agentRegisterCommand(flags, options = {}) {
14133
14293
  entry.mode = options.serve.mode;
14134
14294
  entry.serves = options.serve.serves || [];
14135
14295
  entry.maxConcurrent = options.serve.maxConcurrent;
14296
+ entry.role = options.serve.role || entry.role;
14297
+ entry.persona = options.serve.persona || entry.persona;
14136
14298
  }
14137
14299
  rememberLocalAgent(entry);
14138
14300
  }
@@ -14145,6 +14307,9 @@ async function agentRegisterCommand(flags, options = {}) {
14145
14307
  const accepted = response.accountIds || [];
14146
14308
  console.log('Registered as a support agent: ' + response.agentId);
14147
14309
  console.log(' label ' + payload.label);
14310
+ if (response.role || payload.role) {
14311
+ console.log(' role ' + (response.role || payload.role) + (response.persona ? ' (persona: ' + response.persona + ')' : ''));
14312
+ }
14148
14313
  console.log(' platform ' + baseUrl);
14149
14314
  console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (live support tickets)' : ' (fixtures only)'));
14150
14315
  console.log(' accounts ' + accepted.length + ' account(s) this session can be routed work for');
@@ -14166,6 +14331,9 @@ async function agentRegisterCommand(flags, options = {}) {
14166
14331
  console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
14167
14332
  console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
14168
14333
  console.log(' ' + serve.workerAgentWhy);
14334
+ if (serve.role) {
14335
+ console.log(' role ' + serve.role + (serve.persona && serve.persona !== serve.role ? ' (persona: ' + serve.persona + ')' : ''));
14336
+ }
14169
14337
  console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
14170
14338
  console.log(' serves ' + ((serve.serves && serve.serves.length)
14171
14339
  ? serve.serves.join(', ') + ' (only these workstreams are routed here, and only these are swept)'
@@ -14214,6 +14382,12 @@ async function agentServeCommand(flags) {
14214
14382
  const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
14215
14383
  .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
14216
14384
  const serves = parseServedWorkstreams(flags.serves);
14385
+ const role = textFlag(flags.role) || textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_ROLE);
14386
+ const persona = textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
14387
+ if (role || persona) {
14388
+ console.log('');
14389
+ console.log('Serving as role: ' + (role || persona) + (persona && persona !== role ? ' (persona: ' + persona + ')' : ''));
14390
+ }
14217
14391
  if (serves.length) {
14218
14392
  console.log('');
14219
14393
  console.log('Serving workstreams: ' + serves.join(', '));
@@ -14259,6 +14433,8 @@ async function agentServeCommand(flags) {
14259
14433
  workerAgentWhy: choice.why,
14260
14434
  mode,
14261
14435
  serves,
14436
+ role: role || persona || null,
14437
+ persona: persona || null,
14262
14438
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
14263
14439
  }
14264
14440
  });
@@ -14368,6 +14544,12 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
14368
14544
  '--base-url', entry.baseUrl,
14369
14545
  '--cwd', entry.cwd
14370
14546
  ];
14547
+ if (entry.role) {
14548
+ args.push('--role', entry.role);
14549
+ }
14550
+ if (entry.persona) {
14551
+ args.push('--persona', entry.persona);
14552
+ }
14371
14553
  if (serve) {
14372
14554
  args.push(
14373
14555
  '--serve', 'true',
@@ -14381,6 +14563,12 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
14381
14563
  if (serve.serves && serve.serves.length) {
14382
14564
  args.push('--serves', serve.serves.join(','));
14383
14565
  }
14566
+ if (serve.role && !entry.role) {
14567
+ args.push('--role', serve.role);
14568
+ }
14569
+ if (serve.persona && !entry.persona) {
14570
+ args.push('--persona', serve.persona);
14571
+ }
14384
14572
  }
14385
14573
  const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
14386
14574
  child.unref();
@@ -14389,6 +14577,8 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
14389
14577
  anchorPid: entry.anchorPid,
14390
14578
  heartbeatPid: child.pid,
14391
14579
  serving: !!serve,
14580
+ role: entry.role || (serve ? serve.role : null),
14581
+ persona: entry.persona || (serve ? serve.persona : null),
14392
14582
  workerAgent: serve ? serve.workerAgent : null,
14393
14583
  mode: serve ? serve.mode : null,
14394
14584
  maxConcurrent: serve ? serve.maxConcurrent : null
@@ -14869,6 +15059,8 @@ async function agentHeartbeatDaemonCommand(flags) {
14869
15059
  process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
14870
15060
 
14871
15061
  const serving = flagEnabled(flags.serve);
15062
+ const role = textFlag(flags.role) || textFlag(process.env.REMITS_AGENT_ROLE);
15063
+ const persona = textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
14872
15064
  const ctx = {
14873
15065
  agentId,
14874
15066
  cwd,
@@ -14878,6 +15070,8 @@ async function agentHeartbeatDaemonCommand(flags) {
14878
15070
  mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
14879
15071
  serves: parseServedWorkstreams(flags.serves),
14880
15072
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
15073
+ role: role || persona || null,
15074
+ persona: persona || null,
14881
15075
  runId: 'run-' + Date.now().toString(36)
14882
15076
  };
14883
15077
  const inFlight = new Map();
@@ -15035,6 +15229,8 @@ async function agentHeartbeatDaemonCommand(flags) {
15035
15229
  'agent-id': agentId,
15036
15230
  'base-url': context.baseUrl,
15037
15231
  'max-concurrent': String(ctx.maxConcurrent),
15232
+ role: ctx.role || undefined,
15233
+ persona: ctx.persona || undefined,
15038
15234
  json: 'true'
15039
15235
  },
15040
15236
  // Re-register with THIS supervisor's declared bound and queues. The platform treats an absent
@@ -15042,14 +15238,21 @@ async function agentHeartbeatDaemonCommand(flags) {
15042
15238
  // here: this path runs precisely because the record EXPIRED, so there is nothing left to be
15043
15239
  // unchanged from, and a specialised machine would come back as a generalist that can edit.
15044
15240
  { spawnHeartbeat: false,
15045
- serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent } }
15241
+ serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent, role: ctx.role, persona: ctx.persona } }
15046
15242
  ).catch((err) => {
15047
15243
  appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
15048
15244
  { agentId, error: describeError(err) }, 'error');
15049
15245
  });
15050
15246
  }
15051
15247
  } catch (err) {
15052
- appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
15248
+ // A refusal is the platform saying this user may no longer run this session as declared (a role,
15249
+ // workstream or autonomy was revoked). Recorded as itself so it is not read as a network blip.
15250
+ const body = err && err.response && err.response.data;
15251
+ if (err && err.response && err.response.status === 403 && body && body.refused) {
15252
+ appendGlobalActivityLog('agent', 'serve.refused', { agentId, message: body.message }, 'error');
15253
+ } else {
15254
+ appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
15255
+ }
15053
15256
  }
15054
15257
 
15055
15258
  const interval = !serving
@@ -15077,6 +15280,9 @@ async function agentStatusCommand(flags) {
15077
15280
  if (flags.ticket || flags['ticket-id']) update.ticketId = String(flags.ticket || flags['ticket-id']);
15078
15281
  if (flags.step) update.step = String(flags.step);
15079
15282
  if (flags.detail) update.detail = String(flags.detail);
15283
+ if (flags.role || flags.persona) {
15284
+ throw new Error('A session\'s role is fixed when it registers. Re-register with `remits-cli agent register --role ROLE`.');
15285
+ }
15080
15286
 
15081
15287
  // Reporting a ticket without a state is unambiguous: you are working it.
15082
15288
  if (update.ticketId && !update.state) update.state = 'working';
@@ -15098,6 +15304,7 @@ async function agentStatusCommand(flags) {
15098
15304
  // `||` binds looser than `+`, so writing this as one expression prints only the state and silently
15099
15305
  // drops the ticket and activity — the two things the caller just set.
15100
15306
  const parts = [agent.state || 'idle'];
15307
+ if (agent.role) parts.push('role: ' + agent.role);
15101
15308
  if (agent.ticketId) parts.push('ticket ' + agent.ticketId);
15102
15309
  if (agent.activity) parts.push(agent.activity);
15103
15310
  console.log(parts.join(' '));
@@ -15428,6 +15635,336 @@ async function ticketCommand(flags, subcommand) {
15428
15635
  }
15429
15636
  }
15430
15637
 
15638
+ function csvFlag(value) {
15639
+ if (!value) return undefined;
15640
+ const parts = Array.isArray(value) ? value : [value];
15641
+ const values = parts.flatMap((part) => String(part).split(',').map((v) => v.trim()).filter(Boolean));
15642
+ return values.length ? values : undefined;
15643
+ }
15644
+
15645
+ function buildTicketQueueRequestPayload(flags, accountId, dataMode, defaultLimit = 25) {
15646
+ return {
15647
+ accountId,
15648
+ dataMode,
15649
+ statuses: csvFlag(flags.status || flags.statuses),
15650
+ priorities: csvFlag(flags.priority || flags.priorities),
15651
+ types: csvFlag(flags.type || flags.types),
15652
+ sources: csvFlag(flags.source || flags.sources),
15653
+ workstream: flags.workstream,
15654
+ assignedTo: flags['assigned-to'] || flags.assignedTo,
15655
+ routedAgentId: flags['routed-to'] || flags.routedAgentId,
15656
+ unrouted: flagEnabled(flags.unrouted) || undefined,
15657
+ unassigned: flagEnabled(flags.unassigned) || undefined,
15658
+ // "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
15659
+ // pending_review, which also means "done, review me".
15660
+ awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
15661
+ implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
15662
+ plannedIn: flags['planned-in'] || flags.plannedIn,
15663
+ boardStage: flags['board-stage'] || flags.boardStage,
15664
+ size: flags.size,
15665
+ blockedBy: flags['blocked-by'] || flags.blockedBy,
15666
+ rank: flags.rank,
15667
+ minRank: flags['min-rank'] || flags.minRank,
15668
+ maxRank: flags['max-rank'] || flags.maxRank,
15669
+ search: flags.search || flags.q,
15670
+ // Every one of these reads a field the platform already mirrored and already projected, and that
15671
+ // no surface could ask about. `syncState` in particular is how a broken integration is found:
15672
+ // a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
15673
+ syncStates: csvFlag(flags['sync-state'] || flags.syncState),
15674
+ overdue: flagEnabled(flags.overdue) || undefined,
15675
+ relatedTo: flags['related-to'] || flags.relatedTo,
15676
+ duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
15677
+ causedBy: flags['caused-by'] || flags.causedBy,
15678
+ hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
15679
+ dueBefore: flags['due-before'] || flags.dueBefore,
15680
+ dueAfter: flags['due-after'] || flags.dueAfter,
15681
+ requiresRepositoryWork: firstDefined(flags['repo-work'], flags['requires-repository-work']),
15682
+ // "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
15683
+ // mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
15684
+ minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
15685
+ sortBy: flags['sort-by'] || flags.sortBy,
15686
+ sortDirection: flags['sort-direction'] || flags.sortDirection,
15687
+ includeChildren: flags['include-children'] === 'false' ? false : undefined,
15688
+ limit: parsePositiveInt(flags.limit, defaultLimit)
15689
+ };
15690
+ }
15691
+
15692
+ function ticketActivityMs(ticket) {
15693
+ for (const value of [
15694
+ ticket && ticket.updatedAt,
15695
+ ticket && ticket.updated,
15696
+ ticket && ticket.lastActivityAt,
15697
+ ticket && ticket.activityAt,
15698
+ ticket && ticket.claimedAt,
15699
+ ticket && ticket.assignedAt,
15700
+ ticket && ticket.createdAt
15701
+ ]) {
15702
+ const parsed = Date.parse(value || '');
15703
+ if (Number.isFinite(parsed)) return parsed;
15704
+ }
15705
+ return null;
15706
+ }
15707
+
15708
+ function ticketQueueLocalFilter(flags) {
15709
+ const inactiveDays = parsePositiveInt(firstDefined(
15710
+ flags['inactive-days'], flags.inactiveDays, flags['stale-days'], flags.staleDays
15711
+ ), 0);
15712
+ if (inactiveDays) {
15713
+ const cutoffMs = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
15714
+ return {
15715
+ type: 'inactive-days',
15716
+ cutoffMs,
15717
+ description: 'inactive for at least ' + inactiveDays + ' day(s)',
15718
+ inactiveDays
15719
+ };
15720
+ }
15721
+
15722
+ const before = firstDefined(flags['updated-before'], flags.updatedBefore, flags['inactive-before'], flags.inactiveBefore);
15723
+ if (before) {
15724
+ const cutoffMs = Date.parse(String(before));
15725
+ if (!Number.isFinite(cutoffMs)) {
15726
+ throw new Error('--updated-before expects an ISO date/time or YYYY-MM-DD.');
15727
+ }
15728
+ return {
15729
+ type: 'updated-before',
15730
+ cutoffMs,
15731
+ description: 'updated before ' + new Date(cutoffMs).toISOString(),
15732
+ updatedBefore: new Date(cutoffMs).toISOString()
15733
+ };
15734
+ }
15735
+ return null;
15736
+ }
15737
+
15738
+ function decorateTicketQueueRows(tickets) {
15739
+ const nowMs = Date.now();
15740
+ return (tickets || []).map((ticket) => {
15741
+ const touchedMs = ticketActivityMs(ticket);
15742
+ return {
15743
+ ...ticket,
15744
+ inactiveDays: Number.isFinite(touchedMs) ? Math.floor((nowMs - touchedMs) / (24 * 60 * 60 * 1000)) : null
15745
+ };
15746
+ });
15747
+ }
15748
+
15749
+ function recomputeTicketQueueCounts(tickets) {
15750
+ const counts = {
15751
+ total: (tickets || []).length,
15752
+ routed: 0,
15753
+ unrouted: 0,
15754
+ assigned: 0,
15755
+ unassigned: 0,
15756
+ awaitingResponse: 0,
15757
+ needsHuman: 0
15758
+ };
15759
+ for (const ticket of tickets || []) {
15760
+ if (ticket.routedAgentId || ticket.routedAgentLabel) counts.routed += 1;
15761
+ else counts.unrouted += 1;
15762
+ if (ticket.assignedTo) counts.assigned += 1;
15763
+ else counts.unassigned += 1;
15764
+ if (ticket.awaitingResponseSince) counts.awaitingResponse += 1;
15765
+ if (ticket.needsHuman || ticket.autonomousRunLimitReached || ticket.humanRequired) counts.needsHuman += 1;
15766
+ }
15767
+ return counts;
15768
+ }
15769
+
15770
+ function withTicketQueueLocalFilters(response, flags) {
15771
+ const localFilter = ticketQueueLocalFilter(flags);
15772
+ if (!localFilter) return response;
15773
+ const originalTickets = Array.isArray(response && response.tickets) ? response.tickets : [];
15774
+ const tickets = originalTickets.filter((ticket) => {
15775
+ const touchedMs = ticketActivityMs(ticket);
15776
+ return Number.isFinite(touchedMs) && touchedMs < localFilter.cutoffMs;
15777
+ });
15778
+ return {
15779
+ ...(response || {}),
15780
+ tickets,
15781
+ count: tickets.length,
15782
+ totalCount: tickets.length,
15783
+ totalCountBeforeLocalFilters: response && response.totalCount != null ? response.totalCount : originalTickets.length,
15784
+ localFilters: {
15785
+ inactiveBefore: new Date(localFilter.cutoffMs).toISOString(),
15786
+ description: localFilter.description,
15787
+ pageScoped: true
15788
+ },
15789
+ counts: recomputeTicketQueueCounts(tickets)
15790
+ };
15791
+ }
15792
+
15793
+ async function fetchTicketQueueForAccount(context, accountId, flags, defaultLimit) {
15794
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
15795
+ .post('/cli/tickets', buildTicketQueueRequestPayload(flags, accountId, context.dataMode, defaultLimit))
15796
+ .then((r) => r.data);
15797
+ const tickets = Array.isArray(response && response.tickets) ? decorateTicketQueueRows(response.tickets) : [];
15798
+ return withTicketQueueLocalFilters({
15799
+ ...(response || {}),
15800
+ baseUrl: context.baseUrl,
15801
+ accountId,
15802
+ dataMode: (response && response.dataMode) || context.dataMode,
15803
+ compartment: context.baseUrl + ' · ' + ((response && response.dataMode) || context.dataMode) + ' lane',
15804
+ tickets,
15805
+ searchedAccounts: (response && response.accounts) || []
15806
+ }, flags);
15807
+ }
15808
+
15809
+ function globalTicketQueueAccountEntries(flags, context) {
15810
+ const ids = csvFlag(flags['account-ids'] || flags.accountIds);
15811
+ const wanted = ids ? new Set(ids.map((id) => String(Number(id))).filter((id) => id !== 'NaN')) : null;
15812
+ const byId = {};
15813
+ for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
15814
+ const id = Number(entry.accountId);
15815
+ if (!Number.isFinite(id) || id <= 0) continue;
15816
+ if (wanted && !wanted.has(String(id))) continue;
15817
+ if (!byId[String(id)]) byId[String(id)] = { ...entry, accountId: id };
15818
+ }
15819
+ if (!Object.keys(byId).length && context && context.accountId) {
15820
+ byId[String(context.accountId)] = {
15821
+ accountId: Number(context.accountId),
15822
+ name: (context.session && context.session.account && context.session.account.name) || null,
15823
+ source: 'session'
15824
+ };
15825
+ }
15826
+ const breadth = (entry) => {
15827
+ const type = String(entry.type || '').toUpperCase();
15828
+ if (type === 'PLATFORM') return 0;
15829
+ if (type === 'PRODUCT') return 1;
15830
+ return 2;
15831
+ };
15832
+ return Object.values(byId).sort((a, b) => breadth(a) - breadth(b) || Number(a.accountId) - Number(b.accountId));
15833
+ }
15834
+
15835
+ function aggregateGlobalTicketQueues(queues) {
15836
+ const compartments = supportTicketCompartmentGroups(queues);
15837
+ const tickets = compartments.flatMap((group) => group.tickets.map((ticket) => ({
15838
+ ...ticket,
15839
+ compartment: group.compartment
15840
+ })));
15841
+ return {
15842
+ compartments: compartments.map((group) => ({
15843
+ compartment: group.compartment,
15844
+ baseUrl: group.baseUrl,
15845
+ dataMode: group.dataMode,
15846
+ queueCount: group.independent.length,
15847
+ overlappingQueuesDropped: group.queues.length - group.independent.length,
15848
+ scopeUnknown: group.independent.some((queue) => !(queue.searchedAccounts || []).length),
15849
+ retrievedCount: group.tickets.length,
15850
+ accounts: group.independent.map((queue) => ({
15851
+ accountId: queue.accountId,
15852
+ open: Number(queue.totalCount || 0),
15853
+ scanned: (queue.searchedAccounts || []).length
15854
+ }))
15855
+ })),
15856
+ tickets
15857
+ };
15858
+ }
15859
+
15860
+ function printTicketQueueRows(tickets) {
15861
+ const showsBoard = (tickets || []).some((t) => t.boardStage || t.plannedIn || t.blockedBy);
15862
+ for (const t of tickets || []) {
15863
+ // The repo account, not just the owning account: it is what the edit lease excludes on, so it is
15864
+ // what tells a reader which of these can be worked at the same time.
15865
+ const repo = t.implementationAccountId || t.accountId;
15866
+ const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '-';
15867
+ console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
15868
+ String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
15869
+ 'repo ' + String(repo || '?').padEnd(6) +
15870
+ String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
15871
+ // A second line rather than more columns: these are the account's own labels, of unbounded length,
15872
+ // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
15873
+ const marks = [
15874
+ t.inactiveDays != null ? 'inactive ' + t.inactiveDays + 'd' : '',
15875
+ t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
15876
+ t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
15877
+ showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
15878
+ showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
15879
+ showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
15880
+ ].filter(Boolean);
15881
+ if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
15882
+ }
15883
+ }
15884
+
15885
+ async function ticketGlobalQueueCommand(flags) {
15886
+ const local = listLocalAgents()[0] || null;
15887
+ const context = agentSessionFor(flags, local);
15888
+ const entries = globalTicketQueueAccountEntries(flags, context);
15889
+ if (!entries.length) {
15890
+ throw new Error('ticket queue --all-accounts needs indexed account repos. Run `remits-cli agent repos --rescan`.');
15891
+ }
15892
+
15893
+ const queues = [];
15894
+ const errors = [];
15895
+ const defaultLimit = parsePositiveInt(flags.limit, 500);
15896
+ const concurrency = Math.min(Math.max(parsePositiveInt(flags.concurrency, 4), 1), 8);
15897
+ let cursor = 0;
15898
+ async function worker() {
15899
+ while (cursor < entries.length) {
15900
+ const entry = entries[cursor++];
15901
+ try {
15902
+ const queue = await fetchTicketQueueForAccount(context, Number(entry.accountId), flags, defaultLimit);
15903
+ queues.push({
15904
+ ...queue,
15905
+ accountName: entry.name || queue.accountName || null
15906
+ });
15907
+ } catch (err) {
15908
+ errors.push({
15909
+ accountId: Number(entry.accountId),
15910
+ accountName: entry.name || null,
15911
+ message: describeError(err)
15912
+ });
15913
+ }
15914
+ }
15915
+ }
15916
+ await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, () => worker()));
15917
+
15918
+ const aggregate = aggregateGlobalTicketQueues(queues);
15919
+ const tickets = decorateTicketQueueRows(aggregate.tickets)
15920
+ .sort((a, b) => (ticketActivityMs(b) || 0) - (ticketActivityMs(a) || 0));
15921
+ const counts = recomputeTicketQueueCounts(tickets);
15922
+ const response = {
15923
+ success: queues.length > 0,
15924
+ partial: errors.length > 0,
15925
+ global: true,
15926
+ baseUrl: context.baseUrl,
15927
+ dataMode: context.dataMode,
15928
+ accountCount: entries.length,
15929
+ queueCount: queues.length,
15930
+ errorCount: errors.length,
15931
+ totalCount: tickets.length,
15932
+ counts,
15933
+ localFilters: queues.find((queue) => queue.localFilters)?.localFilters || undefined,
15934
+ compartments: aggregate.compartments,
15935
+ accountErrors: errors,
15936
+ tickets
15937
+ };
15938
+
15939
+ if (flagEnabled(flags.json)) {
15940
+ console.log(JSON.stringify(response, null, 2));
15941
+ return;
15942
+ }
15943
+
15944
+ if (!tickets.length) {
15945
+ console.log('No tickets match across ' + entries.length + ' indexed account queue(s). [' +
15946
+ context.dataMode + ' lane]');
15947
+ if (errors.length) {
15948
+ console.log(errors.length + ' account queue(s) could not be read; use --json for details.');
15949
+ }
15950
+ return;
15951
+ }
15952
+ console.log(tickets.length + ' unique ticket(s) across ' + queues.length + '/' + entries.length +
15953
+ ' account queue(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) +
15954
+ ' unassigned, ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
15955
+ ((counts.needsHuman || 0) ? ', ' + counts.needsHuman + ' NEEDS A PERSON' : '') +
15956
+ ' [' + context.dataMode + ' lane]');
15957
+ if (response.localFilters) {
15958
+ console.log(' local filter: ' + response.localFilters.description +
15959
+ ' (applied to retrieved rows; raise --limit if a root queue was truncated)');
15960
+ }
15961
+ if (errors.length) {
15962
+ console.log(' ' + errors.length + ' account queue(s) could not be read; use --json for details.');
15963
+ }
15964
+ console.log('');
15965
+ printTicketQueueRows(tickets);
15966
+ }
15967
+
15431
15968
  /**
15432
15969
  * The ticket queue — what else is open, and what it looks like as a whole.
15433
15970
  *
@@ -15441,6 +15978,10 @@ async function ticketCommand(flags, subcommand) {
15441
15978
  * was the local dashboard.</p>
15442
15979
  */
15443
15980
  async function ticketQueueCommand(flags) {
15981
+ if (flagEnabled(flags['all-accounts']) || flagEnabled(flags.allAccounts) || flagEnabled(flags.global)) {
15982
+ return ticketGlobalQueueCommand(flags);
15983
+ }
15984
+
15444
15985
  const local = listLocalAgents()[0] || null;
15445
15986
  const context = agentSessionFor(flags, local);
15446
15987
  const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
@@ -15448,46 +15989,7 @@ async function ticketQueueCommand(flags) {
15448
15989
  throw new Error('ticket queue needs --account-id (or run it from an account repo).');
15449
15990
  }
15450
15991
 
15451
- const csv = (value) => (value ? String(value).split(',').map((v) => v.trim()).filter(Boolean) : undefined);
15452
- const response = await buildAxios(context.baseUrl, context.session.token, 30000)
15453
- .post('/cli/tickets', {
15454
- accountId,
15455
- dataMode: context.dataMode,
15456
- statuses: csv(flags.status || flags.statuses),
15457
- priorities: csv(flags.priority || flags.priorities),
15458
- types: csv(flags.type || flags.types),
15459
- sources: csv(flags.source || flags.sources),
15460
- workstream: flags.workstream,
15461
- assignedTo: flags['assigned-to'] || flags.assignedTo,
15462
- routedAgentId: flags['routed-to'] || flags.routedAgentId,
15463
- unrouted: flagEnabled(flags.unrouted) || undefined,
15464
- unassigned: flagEnabled(flags.unassigned) || undefined,
15465
- // "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
15466
- // pending_review, which also means "done, review me".
15467
- awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
15468
- plannedIn: flags['planned-in'] || flags.plannedIn,
15469
- boardStage: flags['board-stage'] || flags.boardStage,
15470
- blockedBy: flags['blocked-by'] || flags.blockedBy,
15471
- search: flags.search || flags.q,
15472
- // Every one of these reads a field the platform already mirrored and already projected, and that
15473
- // no surface could ask about. `syncState` in particular is how a broken integration is found:
15474
- // a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
15475
- syncStates: csv(flags['sync-state'] || flags.syncState),
15476
- overdue: flagEnabled(flags.overdue) || undefined,
15477
- relatedTo: flags['related-to'] || flags.relatedTo,
15478
- duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
15479
- causedBy: flags['caused-by'] || flags.causedBy,
15480
- hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
15481
- dueBefore: flags['due-before'] || flags.dueBefore,
15482
- dueAfter: flags['due-after'] || flags.dueAfter,
15483
- // "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
15484
- // mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
15485
- minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
15486
- sortBy: flags['sort-by'] || flags.sortBy,
15487
- sortDirection: flags['sort-direction'] || flags.sortDirection,
15488
- includeChildren: flags['include-children'] === 'false' ? false : undefined,
15489
- limit: parsePositiveInt(flags.limit, 25)
15490
- }).then((r) => r.data);
15992
+ const response = await fetchTicketQueueForAccount(context, accountId, flags, 25);
15491
15993
 
15492
15994
  if (flagEnabled(flags.json)) {
15493
15995
  console.log(JSON.stringify(response, null, 2));
@@ -15512,30 +16014,12 @@ async function ticketQueueCommand(flags) {
15512
16014
  'and are no longer offered to workers.');
15513
16015
  console.log(' Find them: remits-cli ticket queue --min-runs 1 --unassigned (then answer, reopen, or route one by hand)');
15514
16016
  }
15515
- console.log('');
15516
- // Board position is printed only when some ticket in this queue actually has one. An always-on column
15517
- // would be blank for every account that does not run a board, and a blank column reads as missing data.
15518
- const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
15519
- for (const t of tickets) {
15520
- // The repo account, not just the owning account: it is what the edit lease excludes on, so it is
15521
- // what tells a reader which of these can be worked at the same time.
15522
- const repo = t.implementationAccountId || t.accountId;
15523
- const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
15524
- console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
15525
- String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
15526
- 'repo ' + String(repo || '?').padEnd(6) +
15527
- String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
15528
- // A second line rather than more columns: these are the account's own labels, of unbounded length,
15529
- // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
15530
- const marks = [
15531
- t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
15532
- t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
15533
- showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
15534
- showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
15535
- showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
15536
- ].filter(Boolean);
15537
- if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
16017
+ if (response.localFilters) {
16018
+ console.log(' local filter: ' + response.localFilters.description +
16019
+ ' (applied to retrieved rows; raise --limit if the queue was truncated)');
15538
16020
  }
16021
+ console.log('');
16022
+ printTicketQueueRows(tickets);
15539
16023
  if (response.totalCount > tickets.length) {
15540
16024
  console.log('');
15541
16025
  console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
@@ -15690,12 +16174,16 @@ async function resolveTicketAccountId(context, ticketId) {
15690
16174
  function printTicketHelp() {
15691
16175
  console.log('Usage: remits-cli ticket <action> --ticket ID');
15692
16176
  console.log('');
15693
- console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
16177
+ console.log(' queue [--account-id ID|--all-accounts] [--status open,accepted] [--unrouted] [--search "..."]');
15694
16178
  console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
15695
16179
  console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
15696
16180
  console.log(' on, so it also tells you which of these can be worked at the same time.');
15697
16181
  console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
15698
16182
  console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
16183
+ console.log(' --all-accounts scans indexed account repos, includes descendants, drops overlapping');
16184
+ console.log(' child queues, and keeps per-account read failures in --json instead of aborting.');
16185
+ console.log(' Add --account-ids 4,11 to narrow a sweep, or --inactive-days N /');
16186
+ console.log(' --updated-before YYYY-MM-DD to list stale cleanup candidates.');
15699
16187
  console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
15700
16188
  console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
15701
16189
  console.log(' --overdue lists tickets past a due time their account committed to; --sort-by due');
@@ -15827,8 +16315,10 @@ function describeAgentScope(record) {
15827
16315
  if (!record) return null;
15828
16316
  const serves = Array.isArray(record.serves) ? record.serves.filter(Boolean) : [];
15829
16317
  const readOnly = record.mode === 'investigate' || record.canEdit === false;
15830
- if (!serves.length && !readOnly) return null;
16318
+ const role = record.role || record.persona;
16319
+ if (!serves.length && !readOnly && !role) return null;
15831
16320
  const parts = [];
16321
+ if (role) parts.push('role: ' + role + (record.persona && record.persona !== role ? ' [' + record.persona + ']' : ''));
15832
16322
  parts.push(serves.length ? 'serves ' + serves.join(', ') : 'serves every workstream');
15833
16323
  parts.push(readOnly ? 'investigate only (never edits or stages)' : 'may edit');
15834
16324
  return parts.join(' · ');
@@ -16594,6 +17084,203 @@ async function agentReleaseCommand(flags) {
16594
17084
  }
16595
17085
  }
16596
17086
 
17087
+ async function agentPruneWorktreesCommand(flags) {
17088
+ const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
17089
+ const force = flagEnabled(flags.force);
17090
+ const accountIdFilter = flags['account-id'] ? Number(flags['account-id']) : null;
17091
+
17092
+ try { updateAccountRepoIndex(process.cwd()); } catch (_) {}
17093
+ try { discoverAccountRepos(); } catch (_) {}
17094
+
17095
+ const candidateDirs = new Set();
17096
+ const cwdIdentity = accountCheckoutIdentity(process.cwd());
17097
+ if (fs.existsSync(process.cwd()) &&
17098
+ (!accountIdFilter || (cwdIdentity && cwdIdentity.accountId === accountIdFilter))) {
17099
+ candidateDirs.add(path.resolve(process.cwd()));
17100
+ }
17101
+ try {
17102
+ for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
17103
+ if (entry && entry.directory && fs.existsSync(entry.directory)) {
17104
+ if (accountIdFilter && Number(entry.accountId) !== accountIdFilter) {
17105
+ continue;
17106
+ }
17107
+ candidateDirs.add(path.resolve(entry.directory));
17108
+ }
17109
+ }
17110
+ } catch (_) {}
17111
+
17112
+ const workerStore = readWorkerStore();
17113
+ const isTicketActive = (ticketId) => {
17114
+ if (!ticketId) return false;
17115
+ for (const agentWorkers of Object.values(workerStore.agents || {})) {
17116
+ const worker = agentWorkers[ticketId];
17117
+ if (worker && worker.pid && processIsAlive(worker.pid)) {
17118
+ return true;
17119
+ }
17120
+ }
17121
+ return false;
17122
+ };
17123
+
17124
+ const results = [];
17125
+ for (const baseDir of candidateDirs) {
17126
+ const worktreeRoot = path.join(baseDir, '.remits-cli', TICKET_WORKTREE_DIRNAME);
17127
+ if (!fs.existsSync(worktreeRoot)) {
17128
+ continue;
17129
+ }
17130
+ let subdirs = [];
17131
+ try {
17132
+ subdirs = fs.readdirSync(worktreeRoot, { withFileTypes: true });
17133
+ } catch (_) {
17134
+ continue;
17135
+ }
17136
+
17137
+ for (const dirent of subdirs) {
17138
+ if (!dirent.isDirectory()) continue;
17139
+ const folderName = dirent.name;
17140
+ const worktreePath = path.join(worktreeRoot, folderName);
17141
+ const ticketId = folderName.startsWith('ticket-') ? folderName.replace(/^ticket-/, '') : folderName;
17142
+
17143
+ if (isTicketActive(ticketId)) {
17144
+ results.push({
17145
+ baseDir,
17146
+ worktreePath,
17147
+ ticketId,
17148
+ status: 'skipped',
17149
+ reason: 'active_worker_process'
17150
+ });
17151
+ continue;
17152
+ }
17153
+
17154
+ // Safety is decided from what git ANSWERS, never from its silence: a worktree git cannot report on
17155
+ // is left alone, because removing it is the one outcome that cannot be undone.
17156
+ let isDirty = false;
17157
+ let unreachable = false;
17158
+ try {
17159
+ // Inside the launch checkout, a directory that is NOT its own worktree answers with the PARENT
17160
+ // repository's status — a clean answer about the wrong tree.
17161
+ const topLevel = execSync('git rev-parse --show-toplevel', { cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
17162
+ if (fs.realpathSync(topLevel) !== fs.realpathSync(worktreePath)) {
17163
+ results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'not_a_worktree' });
17164
+ continue;
17165
+ }
17166
+ const status = execSync('git status --porcelain', { cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
17167
+ isDirty = status.length > 0;
17168
+ // Commits no branch, tag or remote-tracking ref holds — a worker on a detached HEAD that committed
17169
+ // and never pushed. Removing the worktree removes the only name they have, and --force does not
17170
+ // override this: those commits are work, not scratch.
17171
+ const orphaned = execSync('git rev-list --max-count=1 HEAD --not --branches --tags --remotes',
17172
+ { cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
17173
+ unreachable = orphaned.length > 0;
17174
+ } catch (_) {
17175
+ results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'git_status_unreadable' });
17176
+ continue;
17177
+ }
17178
+
17179
+ if (unreachable) {
17180
+ results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'unpushed_commits' });
17181
+ continue;
17182
+ }
17183
+
17184
+ if (isDirty && !force) {
17185
+ results.push({
17186
+ baseDir,
17187
+ worktreePath,
17188
+ ticketId,
17189
+ status: 'skipped',
17190
+ reason: 'uncommitted_changes'
17191
+ });
17192
+ continue;
17193
+ }
17194
+
17195
+ if (dryRun) {
17196
+ results.push({
17197
+ baseDir,
17198
+ worktreePath,
17199
+ ticketId,
17200
+ status: 'would_prune',
17201
+ dirty: isDirty
17202
+ });
17203
+ continue;
17204
+ }
17205
+
17206
+ let removed = false;
17207
+ let error = null;
17208
+ try {
17209
+ execSync('git worktree remove --force ' + shellQuote(worktreePath), { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
17210
+ removed = true;
17211
+ } catch (_) {}
17212
+
17213
+ if (fs.existsSync(worktreePath)) {
17214
+ try {
17215
+ fs.rmSync(worktreePath, { recursive: true, force: true });
17216
+ removed = true;
17217
+ } catch (err) {
17218
+ error = describeError(err);
17219
+ }
17220
+ }
17221
+
17222
+ try {
17223
+ execSync('git worktree prune', { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
17224
+ } catch (_) {}
17225
+
17226
+ if (removed) {
17227
+ results.push({
17228
+ baseDir,
17229
+ worktreePath,
17230
+ ticketId,
17231
+ status: 'pruned',
17232
+ dirty: isDirty
17233
+ });
17234
+ } else {
17235
+ results.push({
17236
+ baseDir,
17237
+ worktreePath,
17238
+ ticketId,
17239
+ status: 'failed',
17240
+ error: error || 'Could not remove directory'
17241
+ });
17242
+ }
17243
+ }
17244
+
17245
+ try {
17246
+ execSync('git worktree prune', { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
17247
+ } catch (_) {}
17248
+ }
17249
+
17250
+ if (flagEnabled(flags.json)) {
17251
+ console.log(JSON.stringify({ dryRun, results }, null, 2));
17252
+ return;
17253
+ }
17254
+
17255
+ if (!results.length) {
17256
+ console.log('No ticket worktrees found to prune.');
17257
+ return;
17258
+ }
17259
+
17260
+ const pruned = results.filter((r) => r.status === 'pruned' || r.status === 'would_prune');
17261
+ const skipped = results.filter((r) => r.status === 'skipped');
17262
+ const failed = results.filter((r) => r.status === 'failed');
17263
+
17264
+ console.log((dryRun ? 'DRY RUN: ' : '') + 'Worktree prune results:');
17265
+ for (const item of results) {
17266
+ const label = item.status === 'pruned' ? 'PRUNED'
17267
+ : item.status === 'would_prune' ? 'WOULD PRUNE'
17268
+ : item.status === 'skipped' ? 'SKIPPED (' + item.reason + ')'
17269
+ : 'FAILED (' + (item.error || 'unknown') + ')';
17270
+ console.log(' [' + label + '] ' + item.worktreePath + ' (ticket ' + item.ticketId + ')');
17271
+ }
17272
+ console.log('');
17273
+ console.log('Summary: ' + pruned.length + ' ' + (dryRun ? 'eligible' : 'pruned') + ', ' +
17274
+ skipped.length + ' skipped, ' + failed.length + ' failed.');
17275
+ if (skipped.some((s) => s.reason === 'uncommitted_changes')) {
17276
+ console.log('Tip: pass --force to remove worktrees with uncommitted changes.');
17277
+ }
17278
+ if (skipped.some((s) => s.reason === 'unpushed_commits')) {
17279
+ console.log('Worktrees holding commits no branch or remote has are never removed. Push them, or give');
17280
+ console.log('them a branch (git -C <worktree> branch <name>), then prune again.');
17281
+ }
17282
+ }
17283
+
16597
17284
  async function agentCommand(flags, subcommand) {
16598
17285
  switch (subcommand) {
16599
17286
  case 'register': return agentRegisterCommand(flags);
@@ -16606,6 +17293,7 @@ async function agentCommand(flags, subcommand) {
16606
17293
  case 'inspect': case 'activity': return activityInspectCommand(flags);
16607
17294
  case 'repos': case 'repo': return agentReposCommand(flags);
16608
17295
  case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
17296
+ case 'prune-worktrees': case 'prune': return agentPruneWorktreesCommand(flags);
16609
17297
  case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
16610
17298
  default:
16611
17299
  printAgentHelp();
@@ -16616,14 +17304,17 @@ async function agentCommand(flags, subcommand) {
16616
17304
  function printAgentHelp() {
16617
17305
  console.log('Usage: remits-cli agent <subcommand>');
16618
17306
  console.log('');
16619
- console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
16620
- console.log(' [--serves support,incident,sdlc]');
17307
+ console.log(' serve [--worker-agent claude|codex|gemini] [--role ROLE] [--persona PERSONA]');
17308
+ console.log(' [--max-concurrent N] [--mode edit|investigate] [--serves support,incident,sdlc]');
16621
17309
  console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
16622
17310
  console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
16623
17311
  console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
16624
17312
  console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
16625
17313
  console.log(' Closing the tab stops the agent.');
16626
17314
  console.log('');
17315
+ console.log(' --role / --persona declares the session\'s operational role (e.g. developer, triage,');
17316
+ console.log(' release_manager) — opaque names your OPERATIONS remits-policy rules match. Fixed for the');
17317
+ console.log(' life of the session, and limited by your user\'s allowedAgentRoles.');
16627
17318
  console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
16628
17319
  console.log(' session is running (when you start it from inside one), else your');
16629
17320
  console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
@@ -16638,7 +17329,7 @@ function printAgentHelp() {
16638
17329
  console.log('');
16639
17330
  console.log(' workers [--json] What this session is running right now.');
16640
17331
  console.log('');
16641
- console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
17332
+ console.log(' register [--label NAME] [--role ROLE] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
16642
17333
  console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
16643
17334
  console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
16644
17335
  console.log(' directory — it registers for every account repo indexed on this machine that your');
@@ -16666,6 +17357,9 @@ function printAgentHelp() {
16666
17357
  console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
16667
17358
  console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
16668
17359
  console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
17360
+ console.log(' prune-worktrees [--account-id ID] [--dry-run] [--force] [--json]');
17361
+ console.log(' Safely removes stale or finished ticket worktrees in .remits-cli/ticket-worktrees.');
17362
+ console.log(' Skips worktrees for active workers and uncommitted changes (unless --force).');
16669
17363
  console.log(' release Deregister this session immediately.');
16670
17364
  console.log('');
16671
17365
  console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
@@ -17106,7 +17800,7 @@ function printComponentsHelp(subcommand) {
17106
17800
  return;
17107
17801
  }
17108
17802
  if (subcommand === 'sync') {
17109
- console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--json]');
17803
+ console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--timeout-ms 60000] [--json]');
17110
17804
  console.log(' remits-cli components sync doctor [--json]');
17111
17805
  console.log('');
17112
17806
  console.log('On trunk this reconciles the pushed repository into live component rows.');
@@ -17216,7 +17910,7 @@ function printComponentsHelp(subcommand) {
17216
17910
  console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--wait-change [--lane-id ID] [--timeout 600]] [--json]');
17217
17911
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
17218
17912
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
17219
- console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
17913
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
17220
17914
  console.log(' remits-cli components commit [--yes] [--safe] [--message|-m "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
17221
17915
  console.log('');
17222
17916
  console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
@@ -17457,7 +18151,7 @@ async function main() {
17457
18151
  // then ran `remits-cli agent serve` from a plain tab (the documented way to work tickets, explicitly
17458
18152
  // "no need to be in an account repo") kept the PREVIOUS skill on disk indefinitely: a stale index
17459
18153
  // pointing at reference files that the upgrade had just renamed or removed. Reading and comparing the
17460
- // whole corpus across all three agent targets measures ~3ms, so there is nothing to save by guarding it.
18154
+ // whole corpus across all four agent targets measures ~3ms, so there is nothing to save by guarding it.
17461
18155
  try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
17462
18156
 
17463
18157
  // Best-effort: update account-repo index whenever running from inside a remits repo
@@ -17512,7 +18206,7 @@ async function main() {
17512
18206
  console.log(' remits-cli guides sync [--all] [--base-url URL] [--json] # refresh front-stage guides');
17513
18207
  console.log(' remits-cli listen [stop|status] [--foreground true] # compatibility alias');
17514
18208
  console.log(' remits-cli data-mode [set test|prod]');
17515
- console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
18209
+ console.log(' remits-cli install --skills [--target codex|claude|gemini|antigravity|all] [--overwrite true]');
17516
18210
  console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--as-account ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
17517
18211
  console.log(' remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--as-account ID] [--target-account ID] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
17518
18212
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
@@ -17525,7 +18219,7 @@ async function main() {
17525
18219
  console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--json]');
17526
18220
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
17527
18221
  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]');
17528
- console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
18222
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
17529
18223
  console.log(' remits-cli components commit [--yes] [--safe] [--message|-m \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
17530
18224
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
17531
18225
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');