@phnx-labs/agents-cli 1.20.87 → 1.20.89

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -1667,7 +1667,7 @@ export function registerRunCommand(program) {
1667
1667
  const { pickSessionInteractive } = await import('./sessions.js');
1668
1668
  const { buildContinuePrompt } = await import('../lib/loop.js');
1669
1669
  // Freshen the index for this agent before any lookup (incremental, cached).
1670
- // AgentId is wider than SessionAgentId (cursor/amp/… keep no transcripts);
1670
+ // AgentId is wider than SessionAgentId (amp/kiro/goose/copilot keep no transcripts);
1671
1671
  // those simply yield no matches and fall through to the not-found error.
1672
1672
  const sessionAgent = agent;
1673
1673
  await discoverSessions({ agent: sessionAgent, version });
@@ -1899,8 +1899,8 @@ export function registerRunCommand(program) {
1899
1899
  // safest native mode (modes[0], typically edit). That covers both the
1900
1900
  // implicit default and an explicit `--mode plan`, so multi-agent
1901
1901
  // scripts can pass a uniform plan flag without per-agent branching.
1902
- // Elevation is never silent: we warn on stderr (yellow when the user
1903
- // explicitly asked for plan; gray for the implicit default / auto).
1902
+ // Mode degradation is never silent: buildExecCommand emits one stderr
1903
+ // warning for the requested-to-resolved transition unless --quiet is set.
1904
1904
  // `skip` still hard-fails when unsupported — pretending we bypassed
1905
1905
  // permissions would be unsafe.
1906
1906
  const modeIsDefault = modeSource === 'default';
@@ -1913,20 +1913,7 @@ export function registerRunCommand(program) {
1913
1913
  console.error(chalk.red(err.message));
1914
1914
  process.exit(1);
1915
1915
  }
1916
- if (resolvedMode !== requestedMode) {
1917
- mode = resolvedMode;
1918
- if (!options.quiet) {
1919
- if (requestedMode === 'plan' && !modeIsDefault) {
1920
- process.stderr.write(chalk.yellow(`[agents] ${agent} has no read-only 'plan' mode; using '${mode}' (writable) instead. Pass --mode ${mode} to silence this.\n`));
1921
- }
1922
- else {
1923
- process.stderr.write(chalk.gray(`[agents] ${agent} has no '${requestedMode}' mode; using '${mode}'\n`));
1924
- }
1925
- }
1926
- }
1927
- else {
1928
- mode = resolvedMode;
1929
- }
1916
+ mode = resolvedMode;
1930
1917
  // Fail fast on the headless-plan stall footgun: a slash command run
1931
1918
  // headless under the implicit default 'plan' mode hangs forever at
1932
1919
  // ExitPlanMode (no TTY to approve the plan). Tell the user how to fix it
@@ -1935,7 +1922,7 @@ export function registerRunCommand(program) {
1935
1922
  const stallCmd = headlessPlanStallCommand({
1936
1923
  prompt,
1937
1924
  interactive: options.interactive,
1938
- mode,
1925
+ mode: resolvedMode,
1939
1926
  modeIsDefault,
1940
1927
  });
1941
1928
  if (stallCmd) {
@@ -2029,7 +2016,7 @@ export function registerRunCommand(program) {
2029
2016
  version,
2030
2017
  prompt,
2031
2018
  interactive: options.interactive || forceInteractive,
2032
- mode,
2019
+ mode: requestedMode,
2033
2020
  effort,
2034
2021
  cwd: options.cwd,
2035
2022
  model,
@@ -2040,6 +2027,7 @@ export function registerRunCommand(program) {
2040
2027
  name: options.name,
2041
2028
  resume: resumeNative,
2042
2029
  verbose: options.verbose,
2030
+ modeWarningState: { quiet: options.quiet },
2043
2031
  // --raw, --no-tmux (commander negation → options.tmux === false), and
2044
2032
  // --disable-tmux all bypass the interactive tmux wrapper. AGENTS_NO_TMUX=1
2045
2033
  // does the same via the env check in exec.ts.
@@ -6,6 +6,7 @@ import { homedir } from 'os';
6
6
  import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
7
7
  import { insertTask } from '../lib/cloud/store.js';
8
8
  import { emit } from '../lib/events.js';
9
+ import { buildFactorySnapshot } from '../lib/factory/snapshot.js';
9
10
  function requireFactoryUrl() {
10
11
  const url = process.env.FACTORY_FLOOR_URL;
11
12
  if (!url) {
@@ -51,14 +52,28 @@ export function registerFactoryCommands(program) {
51
52
  Examples:
52
53
  agents factory submit PROJ-123
53
54
  agents factory submit https://linear.app/example/issue/PROJ-123
55
+ agents factory snapshot --json
54
56
  `);
55
- factory.hook('preAction', () => {
56
- if (enabled)
57
+ factory.hook('preAction', (_thisCommand, actionCommand) => {
58
+ // Foreman must be able to read its tick input before any beta-gated action.
59
+ if (enabled || actionCommand.name() === 'snapshot')
57
60
  return;
58
61
  console.error(chalk.red('agents factory is in beta.'));
59
62
  console.error(chalk.gray(betaEnableHint('factory')));
60
63
  process.exit(1);
61
64
  });
65
+ factory
66
+ .command('snapshot')
67
+ .description('Read the complete Software Factory state without dispatching or changing it.')
68
+ .option('--json', 'Output the stable machine-readable snapshot')
69
+ .action(async (opts) => {
70
+ const snapshot = await buildFactorySnapshot();
71
+ if (opts.json) {
72
+ console.log(JSON.stringify(snapshot, null, 2));
73
+ return;
74
+ }
75
+ renderSnapshot(snapshot);
76
+ });
62
77
  factory
63
78
  .command('submit <linear-ref>')
64
79
  .description('Submit a Linear issue (PROJ-123 or URL) to the Software Factory.')
@@ -93,3 +108,12 @@ Examples:
93
108
  console.log(` tail output agents cloud logs ${result.cloud_execution_id}`);
94
109
  });
95
110
  }
111
+ function renderSnapshot(snapshot) {
112
+ console.log(chalk.bold(`Factory snapshot ${snapshot.generatedAt}`));
113
+ console.log(` sessions ${snapshot.sessions.length}`);
114
+ console.log(` open PRs ${snapshot.prs.length}`);
115
+ console.log(` devices ${snapshot.devices.length}`);
116
+ for (const [project, queue] of Object.entries(snapshot.queues)) {
117
+ console.log(` ${project.padEnd(12)} todo ${queue.todo} · in progress ${queue.inProgress} · blocked ${queue.blocked}`);
118
+ }
119
+ }
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { buildFunnelStatusCommand, buildFunnelUpCommand, parseFunnelPort } from '../lib/funnel.js';
2
+ import { buildFunnelDownCommand, buildFunnelStatusCommand, buildFunnelUpCommand, parseFunnelPort } from '../lib/funnel.js';
3
3
  import { resolveHost } from '../lib/hosts/registry.js';
4
4
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
5
5
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -59,4 +59,19 @@ export function registerFunnelCommand(program) {
59
59
  process.exit(1);
60
60
  }
61
61
  });
62
+ funnel
63
+ .command('down <host>')
64
+ .description('Disable Tailscale Funnel exposure for a public HTTPS port.')
65
+ .option('--port <n>', 'Public Funnel port: 443, 8443, or 10000', '443')
66
+ .action(async (host, opts) => {
67
+ try {
68
+ const publicPort = parseFunnelPort(opts.port ?? '443');
69
+ await runOnHost(host, buildFunnelDownCommand(publicPort));
70
+ console.log(chalk.green(`Funnel disabled on ${host}: public :${publicPort}`));
71
+ }
72
+ catch (err) {
73
+ console.error(chalk.red(err.message));
74
+ process.exit(1);
75
+ }
76
+ });
62
77
  }
@@ -32,6 +32,7 @@ import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js'
32
32
  import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
33
33
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
34
34
  import { countSessionsInScope } from '../lib/session/discover.js';
35
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
35
36
  import { damerauLevenshtein } from '../lib/fuzzy.js';
36
37
  import { terminalWidth, truncateToWidth, stringWidth, stripAnsi } from '../lib/session/width.js';
37
38
  /** Resource kinds the inspect command can drill into. */
@@ -1256,14 +1257,11 @@ function safeStat(p) {
1256
1257
  return null;
1257
1258
  }
1258
1259
  }
1259
- const SESSION_AGENTS = new Set([
1260
- 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid',
1261
- ]);
1262
1260
  function safeCountSessions(agent) {
1263
- if (!SESSION_AGENTS.has(agent))
1261
+ if (!isSessionTrackedAgent(agent))
1264
1262
  return 0;
1265
1263
  try {
1266
- return countSessionsInScope({ agent: agent });
1264
+ return countSessionsInScope({ agent });
1267
1265
  }
1268
1266
  catch {
1269
1267
  return 0;
@@ -7,7 +7,8 @@
7
7
  * user; these commands are the manual override.
8
8
  */
9
9
  import chalk from 'chalk';
10
- import { enableMenubarService, disableMenubarService, getMenubarStatus, } from '../lib/menubar/install-menubar.js';
10
+ import { setHelpSections } from '../lib/help.js';
11
+ import { enableMenubarService, disableMenubarService, getMenubarStatus, runMenubarSetup, } from '../lib/menubar/install-menubar.js';
11
12
  function notMac() {
12
13
  if (process.platform !== 'darwin') {
13
14
  console.log(chalk.yellow('The menu bar helper is macOS only.'));
@@ -15,10 +16,122 @@ function notMac() {
15
16
  }
16
17
  return false;
17
18
  }
19
+ /** Shared status readout — `status`, bare `menubar`, and `setup --check` all end here. */
20
+ function printStatus(s, opts = {}) {
21
+ const yn = (b) => (b ? chalk.green('yes') : chalk.gray('no'));
22
+ console.log(chalk.bold('Menu bar helper\n'));
23
+ console.log(` running ${yn(s.running)}`);
24
+ console.log(` service installed ${yn(s.serviceInstalled)}`);
25
+ if (opts.brief) {
26
+ console.log(chalk.gray('\n setup | enable | disable | status'));
27
+ return;
28
+ }
29
+ console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
30
+ console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
+ console.log(` current version ${chalk.gray(s.currentVersion)}`);
32
+ console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
33
+ console.log(` disabled by user ${yn(s.disabledByUser)}`);
34
+ // Two copies of the INSTALLED bundle is the duplicate the user sees as two
35
+ // agents marks in the menu bar. It used to read as a healthy `running: yes`.
36
+ if (s.instances.length > 1) {
37
+ console.log(chalk.yellow(`\n ${s.instances.length} copies of the installed helper are running — that is the duplicate menu-bar icon:`));
38
+ for (const p of s.instances)
39
+ console.log(chalk.gray(` ${p.pid} ${p.executable}`));
40
+ console.log(chalk.gray(' Fix it with `agents menubar setup`.'));
41
+ }
42
+ if (s.foreignInstances.length > 0) {
43
+ // RegisterEventHotKey is first-come, so the helper that registered the
44
+ // chord first owns Cmd-Shift-V/O. A process list cannot say which that
45
+ // was — only that a rival exists — so report the conflict, not a winner.
46
+ // The loser has no other symptom: its chords simply never fire.
47
+ const n = s.foreignInstances.length;
48
+ console.log(chalk.yellow(`\n ${n} other helper process${n === 1 ? '' : 'es'} running — ${n === 1 ? 'it' : 'they'} may hold Cmd-Shift-V/O instead of the installed one:`));
49
+ for (const p of s.foreignInstances)
50
+ console.log(chalk.gray(` ${p.pid} ${p.executable}`));
51
+ console.log(chalk.gray(' End them with `agents menubar setup`.'));
52
+ }
53
+ if (s.stale) {
54
+ console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar setup` now.'));
55
+ }
56
+ else if (!s.serviceInstalled && !s.disabledByUser) {
57
+ console.log(chalk.gray('\n Set it up with `agents menubar setup`.'));
58
+ }
59
+ }
60
+ function printSetupResult(r) {
61
+ console.log(chalk.bold('Menu bar setup\n'));
62
+ for (const step of r.steps) {
63
+ const mark = step.outcome === 'failed' ? chalk.red('✗')
64
+ : step.outcome === 'changed' ? chalk.green('+') : chalk.green('✓');
65
+ console.log(` ${mark} ${step.name.padEnd(15)} ${chalk.gray(step.detail)}`);
66
+ }
67
+ console.log();
68
+ if (r.configured) {
69
+ console.log(chalk.green('Menu bar configured.') + chalk.gray(' One agents mark, started at login.'));
70
+ }
71
+ else {
72
+ console.log(chalk.red('Menu bar not fully configured.') + chalk.gray(' See the failed step above.'));
73
+ }
74
+ }
18
75
  export function registerMenubarCommands(program) {
19
76
  const menubar = program
20
77
  .command('menubar')
21
78
  .description('Manage the macOS menu-bar helper (running sessions, agents awaiting input, routines)');
79
+ // `setup` is the one command that gets a machine to the intended state:
80
+ // exactly one status item, started at login. `enable` stays the narrow
81
+ // install+start; setup adds duplicate cleanup and verifies the end state.
82
+ const setup = menubar
83
+ .command('setup')
84
+ .description('Configure the menu bar end-to-end: one instance, started at login')
85
+ .option('--check', 'Report the current state, change nothing')
86
+ .option('--json', 'Emit machine-readable JSON')
87
+ .action((options) => {
88
+ if (options.check) {
89
+ const s = getMenubarStatus();
90
+ if (options.json) {
91
+ process.stdout.write(JSON.stringify(s) + '\n');
92
+ return;
93
+ }
94
+ if (notMac())
95
+ return;
96
+ printStatus(s);
97
+ return;
98
+ }
99
+ if (!options.json && notMac())
100
+ return;
101
+ const r = runMenubarSetup();
102
+ if (options.json) {
103
+ process.stdout.write(JSON.stringify(r) + '\n');
104
+ }
105
+ else {
106
+ printSetupResult(r);
107
+ }
108
+ if (!r.configured)
109
+ process.exitCode = 1;
110
+ });
111
+ setHelpSections(setup, {
112
+ examples: `
113
+ # Configure the menu bar end-to-end (idempotent — safe to re-run)
114
+ agents menubar setup
115
+
116
+ # Two agents marks in the menu bar? This ends the duplicate.
117
+ agents menubar setup
118
+
119
+ # See the current state without changing anything
120
+ agents menubar setup --check
121
+ `,
122
+ notes: `
123
+ Configures, in order: every running helper ended, the helper bundle at
124
+ ~/Library/Application Support/agents-cli, its code signature, the launchd
125
+ login item (com.phnx-labs.agents-menubar — RunAtLoad + KeepAlive), then
126
+ verifies exactly one helper came back up.
127
+
128
+ Every running helper is ended and launchd restarts one, so the survivor is
129
+ always the login-managed copy. Exits nonzero if it cannot reach that state.
130
+
131
+ Setup clears a previous \`agents menubar disable\`. To turn the menu bar off
132
+ again, run \`agents menubar disable\`.
133
+ `,
134
+ });
22
135
  menubar
23
136
  .command('enable')
24
137
  .description('Install and start the menu-bar helper (launches at login)')
@@ -40,7 +153,7 @@ export function registerMenubarCommands(program) {
40
153
  if (notMac())
41
154
  return;
42
155
  disableMenubarService();
43
- console.log(chalk.green('Menu bar helper disabled.') + chalk.gray(' Re-enable any time with `agents menubar enable`.'));
156
+ console.log(chalk.green('Menu bar helper disabled.') + chalk.gray(' Re-enable any time with `agents menubar setup`.'));
44
157
  });
45
158
  menubar
46
159
  .command('status')
@@ -56,33 +169,7 @@ export function registerMenubarCommands(program) {
56
169
  console.log(chalk.yellow('The menu bar helper is macOS only.'));
57
170
  return;
58
171
  }
59
- const yn = (b) => (b ? chalk.green('yes') : chalk.gray('no'));
60
- console.log(chalk.bold('Menu bar helper\n'));
61
- console.log(` running ${yn(s.running)}`);
62
- console.log(` service installed ${yn(s.serviceInstalled)}`);
63
- console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
64
- console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
65
- console.log(` current version ${chalk.gray(s.currentVersion)}`);
66
- console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
67
- console.log(` disabled by user ${yn(s.disabledByUser)}`);
68
- if (s.foreignInstances.length > 0) {
69
- // RegisterEventHotKey is first-come, so the helper that registered the
70
- // chord first owns Cmd-Shift-V/O. A process list cannot say which that
71
- // was — only that a rival exists — so report the conflict, not a
72
- // winner. The loser
73
- // has no other symptom: its chords simply never fire.
74
- const n = s.foreignInstances.length;
75
- console.log(chalk.yellow(`\n ${n} other helper process${n === 1 ? '' : 'es'} running — ${n === 1 ? 'it' : 'they'} may hold Cmd-Shift-V/O instead of the installed one:`));
76
- for (const p of s.foreignInstances)
77
- console.log(chalk.gray(` ${p.pid} ${p.executable}`));
78
- console.log(chalk.gray(' End it, then `agents menubar enable` to restart the installed helper.'));
79
- }
80
- if (s.stale) {
81
- console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar enable` now.'));
82
- }
83
- else if (!s.serviceInstalled && !s.disabledByUser) {
84
- console.log(chalk.gray('\n Enable it with `agents menubar enable`.'));
85
- }
172
+ printStatus(s);
86
173
  });
87
174
  // Bare `agents menubar` -> status.
88
175
  menubar.action(() => {
@@ -91,10 +178,6 @@ export function registerMenubarCommands(program) {
91
178
  console.log(chalk.yellow('The menu bar helper is macOS only.'));
92
179
  return;
93
180
  }
94
- const yn = (b) => (b ? chalk.green('yes') : chalk.gray('no'));
95
- console.log(chalk.bold('Menu bar helper\n'));
96
- console.log(` running ${yn(s.running)}`);
97
- console.log(` service installed ${yn(s.serviceInstalled)}`);
98
- console.log(chalk.gray('\n enable | disable | status'));
181
+ printStatus(s, { brief: true });
99
182
  });
100
183
  }
@@ -19,7 +19,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
19
19
  import { getRoutinesDir } from '../lib/state.js';
20
20
  import { IS_WINDOWS } from '../lib/platform/index.js';
21
21
  import { safeJoin } from '../lib/paths.js';
22
- import { executeJob, executeJobDetached, monitorRunningJobs } from '../lib/runner.js';
22
+ import { executeJob, executeJobDetached, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
23
23
  import { JobScheduler } from '../lib/scheduler.js';
24
24
  import { detectOverdueJobs } from '../lib/overdue.js';
25
25
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
@@ -70,6 +70,8 @@ function fireConditionLabel(job) {
70
70
  job.trigger.action ? `action=${job.trigger.action}` : null,
71
71
  job.trigger.teamKey ? `team=${job.trigger.teamKey}` : null,
72
72
  job.trigger.label ? `label=${job.trigger.label}` : null,
73
+ job.trigger.stateTo ? `stateTo=${job.trigger.stateTo}` : null,
74
+ job.trigger.stateFrom ? `stateFrom=${job.trigger.stateFrom}` : null,
73
75
  ].filter(Boolean).join(', ');
74
76
  return `on linear:${job.trigger.event}${filters ? ` (${filters})` : ''}`;
75
77
  }
@@ -261,6 +263,10 @@ function parseRoutineTrigger(options) {
261
263
  trigger.teamKey = options.teamKey;
262
264
  if (typeof options.label === 'string')
263
265
  trigger.label = options.label;
266
+ if (typeof options.stateTo === 'string')
267
+ trigger.stateTo = options.stateTo;
268
+ if (typeof options.stateFrom === 'string')
269
+ trigger.stateFrom = options.stateFrom;
264
270
  return trigger;
265
271
  }
266
272
  throw new Error('--on source must be github or linear');
@@ -572,7 +578,7 @@ export function registerRoutinesCommands(program) {
572
578
  .command('add [nameOrPath]')
573
579
  .description('Create a new routine from a YAML file or inline flags. Starts the scheduler automatically if it is not already running.')
574
580
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
575
- .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, antigravity, cursor, or opencode')
581
+ .option('-a, --agent <agent>', `Which agent runs this routine: ${ROUTINE_AGENT_IDS.join(', ')}`)
576
582
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
577
583
  .option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
578
584
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
@@ -591,6 +597,8 @@ export function registerRoutinesCommands(program) {
591
597
  .option('--action <name>', 'Webhook action filter for --on triggers (GitHub: labeled/opened; Linear: update)')
592
598
  .option('--team-key <key>', 'Linear team key filter for --on linear:<event> (e.g. RUSH)')
593
599
  .option('--label <name>', 'Label filter for --on triggers (GitHub label name or Linear issue label)')
600
+ .option('--state-to <name>', 'Linear current-state filter for --on linear:<event> (e.g. Plan)')
601
+ .option('--state-from <name>', 'Linear previous-state filter for --on linear:<event> (e.g. Triage)')
594
602
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
595
603
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
596
604
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
@@ -884,12 +892,26 @@ export function registerRoutinesCommands(program) {
884
892
  routinesCmd
885
893
  .command('edit [name]')
886
894
  .description('Open a routine in $EDITOR. Creates a new YAML template if the routine does not exist.')
887
- .action(async (name) => {
895
+ .option('--state-to <name>', 'Update the Linear current-state filter before opening the editor')
896
+ .option('--state-from <name>', 'Update the Linear previous-state filter before opening the editor')
897
+ .action(async (name, options) => {
888
898
  if (!name) {
889
899
  name = await pickJob('Select job to edit', undefined, ['agents routines edit <name>']) ?? undefined;
890
900
  if (!name)
891
901
  return;
892
902
  }
903
+ const existing = readJob(name);
904
+ if (existing && (options.stateTo !== undefined || options.stateFrom !== undefined)) {
905
+ if (!existing.trigger || existing.trigger.type !== 'linear_event') {
906
+ console.error(chalk.red(`'${name}' does not have a Linear trigger; --state-to/--state-from only apply to linear triggers`));
907
+ process.exit(1);
908
+ }
909
+ if (options.stateTo !== undefined)
910
+ existing.trigger.stateTo = options.stateTo || undefined;
911
+ if (options.stateFrom !== undefined)
912
+ existing.trigger.stateFrom = options.stateFrom || undefined;
913
+ writeJob(existing);
914
+ }
893
915
  const jobPath = getJobPath(name);
894
916
  if (!jobPath) {
895
917
  // Job doesn't exist - create a new one
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `agents secrets rotate-passphrase` — rotate the encrypted file store's
3
+ * machine-local master passphrase (RUSH-1975).
4
+ *
5
+ * Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
6
+ * 0600 key file in place, atomically: the new store is staged, verified
7
+ * (round-trip + count), fsync'd, then swapped by directory rename, so a crash
8
+ * leaves the old store intact and readable. Headless-safe and Linux-first — the
9
+ * remediation path for a compromised passphrase (RUSH-1968), where the two
10
+ * supported alternatives are unacceptable (a hand-rolled non-atomic script, or
11
+ * export-to-plaintext which is the exposure being fixed).
12
+ *
13
+ * Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
14
+ */
15
+ import type { Command } from 'commander';
16
+ /** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
17
+ export declare function registerSecretsRotatePassphraseCommand(secrets: Command): void;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `agents secrets rotate-passphrase` — rotate the encrypted file store's
3
+ * machine-local master passphrase (RUSH-1975).
4
+ *
5
+ * Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
6
+ * 0600 key file in place, atomically: the new store is staged, verified
7
+ * (round-trip + count), fsync'd, then swapped by directory rename, so a crash
8
+ * leaves the old store intact and readable. Headless-safe and Linux-first — the
9
+ * remediation path for a compromised passphrase (RUSH-1968), where the two
10
+ * supported alternatives are unacceptable (a hand-rolled non-atomic script, or
11
+ * export-to-plaintext which is the exposure being fixed).
12
+ *
13
+ * Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
14
+ */
15
+ import chalk from 'chalk';
16
+ import { machinePassphraseExists, rotatePassphrase } from '../lib/secrets/filestore.js';
17
+ import { agentStatus } from '../lib/secrets/agent.js';
18
+ /** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
19
+ export function registerSecretsRotatePassphraseCommand(secrets) {
20
+ secrets
21
+ .command('rotate-passphrase')
22
+ .description('Re-key the encrypted file store under a new machine-local passphrase (atomic, headless-safe). Dry-run by default.')
23
+ .option('--commit', 'Perform the rotation (default is dry-run reporting only)')
24
+ .option('--dry-run', 'Report bundle count and round-trip result without re-keying (the default). Still heals an interrupted rotation — that is the one thing it writes.')
25
+ .option('--force', 'Override the safety refusals (held broker unlocks, or a passphrase exported in the environment)')
26
+ .addHelpText('after', `
27
+ Rotates the auto-provisioned file-store key at ~/.agents/.secrets-key/passphrase:
28
+ decrypts every item under the current key, re-encrypts under a new one, verifies
29
+ every item round-trips, then swaps the store and key file atomically. A crash
30
+ before the swap leaves the old store readable with the old key; a crash inside the
31
+ swap self-heals on the next rotate-passphrase run (not on an ordinary get). No
32
+ plaintext secret value or passphrase is ever written to disk, argv, or a log.
33
+
34
+ Examples:
35
+ # Report what would rotate (no writes)
36
+ agents secrets rotate-passphrase
37
+
38
+ # Perform the rotation
39
+ agents secrets rotate-passphrase --commit`)
40
+ .action(async (opts) => {
41
+ try {
42
+ if (opts.commit && opts.dryRun) {
43
+ throw new Error('--commit and --dry-run are mutually exclusive.');
44
+ }
45
+ const dryRun = !opts.commit;
46
+ if (!machinePassphraseExists()) {
47
+ throw new Error('No machine-local passphrase is provisioned on this box, so there is nothing to rotate. ' +
48
+ 'This command re-keys the file store\'s auto-provisioned key at ~/.agents/.secrets-key/passphrase.');
49
+ }
50
+ // Guard 1: refuse while the secrets-agent holds live unlocks (macOS), so a
51
+ // concurrent read cannot land against a half-swapped store. --force overrides.
52
+ if (!dryRun && !opts.force) {
53
+ const held = await agentStatus();
54
+ if (held.length > 0) {
55
+ throw new Error(`The secrets-agent is holding ${held.length} unlocked bundle(s) (${held.map((e) => e.name).join(', ')}). ` +
56
+ 'Lock them first (`agents secrets lock --all`) so no concurrent read races the rotation, or pass --force.');
57
+ }
58
+ }
59
+ // Guard 2: a passphrase exported in the environment shadows the on-disk key
60
+ // file with a now-stale value after the rotation, breaking every read. This
61
+ // is the exact RUSH-1968 footgun — refuse the commit and point at the fix.
62
+ if (!dryRun && !opts.force && (process.env.AGENTS_SECRETS_PASSPHRASE ?? '').length > 0) {
63
+ throw new Error('AGENTS_SECRETS_PASSPHRASE is set in this environment; it would shadow the rotated key file ' +
64
+ 'with a stale value and break every read. Unset it (this is the RUSH-1968 fix) before rotating, or pass --force.');
65
+ }
66
+ const report = rotatePassphrase({ dryRun });
67
+ console.log(`${chalk.bold(String(report.bundleCount))} item(s) decrypt under the current key and ` +
68
+ `${report.roundTripOk ? chalk.green('round-trip cleanly') : chalk.red('failed to round-trip')} under a new key.`);
69
+ if (report.skipped.length > 0) {
70
+ console.log(chalk.gray(`skipped ${report.skipped.length} orphan file(s) (not re-keyed):`));
71
+ for (const s of report.skipped)
72
+ console.log(chalk.gray(` - ${s}`));
73
+ }
74
+ if (report.dryRun) {
75
+ if (report.recoveredInterruptedRotation) {
76
+ // Be precise: this run DID write. Recovery is deliberately not gated on
77
+ // --commit, because healing is how a crashed store becomes readable
78
+ // again without re-keying it.
79
+ console.log(chalk.yellow('Recovered an interrupted rotation: the store was healed back to a single ' +
80
+ 'readable state. That is the only thing this dry run wrote — no re-keying happened.'));
81
+ }
82
+ else {
83
+ console.log(chalk.gray('Dry-run: nothing written.'));
84
+ }
85
+ console.log(chalk.gray(`Pass --commit to re-encrypt the store and swap the key file (${report.keyFilePath}).`));
86
+ return;
87
+ }
88
+ console.log(chalk.green(`Rotated: re-encrypted ${report.bundleCount} item(s) and rewrote ${report.keyFilePath} (mode 0600).`));
89
+ console.log(chalk.gray('The previous key and ciphertext were removed. Any process still holding the old passphrase in its environment must be restarted.'));
90
+ }
91
+ catch (err) {
92
+ console.error(chalk.red(err.message));
93
+ process.exit(1);
94
+ }
95
+ });
96
+ }
@@ -38,6 +38,7 @@ import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
38
38
  import { registerSecretsSyncCommands } from './secrets-sync.js';
39
39
  import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
40
40
  import { registerSecretsImportKeyringCommand } from './secrets-import.js';
41
+ import { registerSecretsRotatePassphraseCommand } from './secrets-rotate-passphrase.js';
41
42
  /** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
42
43
  async function promptForSecret(message) {
43
44
  if (!isInteractiveTerminal()) {
@@ -2419,6 +2420,7 @@ Examples:
2419
2420
  registerSecretsSyncCommands(cmd);
2420
2421
  registerSecretsMigrateAclCommand(cmd);
2421
2422
  registerSecretsImportKeyringCommand(cmd);
2423
+ registerSecretsRotatePassphraseCommand(cmd);
2422
2424
  }
2423
2425
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
2424
2426
  * caller's try/catch renders it and exits). Accepts the legacy `biometry` /
@@ -136,11 +136,17 @@ export declare function linkCwdCell(s: Pick<SessionMeta, 'cwd' | '_remote'>, lab
136
136
  * (null when unknown) alongside the raw fields, so every active row is joinable.
137
137
  * `project` uses the same derivation SessionMeta does — basename(cwd) (see
138
138
  * discover.ts) — so the active view and the history view join identically.
139
+ *
140
+ * `viewingIn` flattens to the same display string the row renderer prints —
141
+ * `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
142
+ * session from an orphaned one (its terminal died, the agent is still running)
143
+ * without re-implementing the tmux client lookup.
139
144
  */
140
- export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<ActiveSession & {
145
+ export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<Omit<ActiveSession, 'viewingIn'> & {
141
146
  ticketId: string | null;
142
147
  project: string | null;
143
148
  prLink: string | null;
149
+ viewingIn: string | null;
144
150
  }>;
145
151
  /**
146
152
  * Compact owner display for the `--active` owner column: the local-part of a