@phnx-labs/agents-cli 1.22.7 → 1.22.9

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 (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +61 -0
  5. package/dist/commands/exec.js +89 -21
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -2
  8. package/dist/commands/harness.js +316 -20
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/profiles.d.ts +42 -0
  11. package/dist/commands/profiles.js +91 -3
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/run-account-picker.js +2 -0
  14. package/dist/commands/sessions-picker.js +3 -3
  15. package/dist/commands/sessions-render.d.ts +12 -0
  16. package/dist/commands/sessions-render.js +124 -0
  17. package/dist/commands/sessions.js +28 -1
  18. package/dist/commands/snapshot.d.ts +11 -0
  19. package/dist/commands/snapshot.js +107 -0
  20. package/dist/commands/ssh.js +26 -3
  21. package/dist/commands/teams.js +14 -2
  22. package/dist/commands/view.d.ts +7 -0
  23. package/dist/commands/view.js +1 -1
  24. package/dist/index.js +11 -1
  25. package/dist/lib/browser/ipc.js +2 -0
  26. package/dist/lib/browser/remote-control.d.ts +35 -0
  27. package/dist/lib/browser/remote-control.js +48 -0
  28. package/dist/lib/browser/service.d.ts +19 -0
  29. package/dist/lib/browser/service.js +19 -1
  30. package/dist/lib/browser/types.d.ts +14 -2
  31. package/dist/lib/crabbox/cli.d.ts +35 -0
  32. package/dist/lib/crabbox/cli.js +46 -0
  33. package/dist/lib/crabbox/config.d.ts +21 -0
  34. package/dist/lib/crabbox/config.js +43 -0
  35. package/dist/lib/crabbox/lease.d.ts +15 -5
  36. package/dist/lib/crabbox/lease.js +57 -17
  37. package/dist/lib/daemon.js +12 -11
  38. package/dist/lib/device-config.js +8 -0
  39. package/dist/lib/devices/resolve-target.d.ts +4 -3
  40. package/dist/lib/devices/resolve-target.js +4 -3
  41. package/dist/lib/hosts/passthrough.d.ts +10 -1
  42. package/dist/lib/hosts/passthrough.js +41 -3
  43. package/dist/lib/hosts/registry.d.ts +4 -0
  44. package/dist/lib/hosts/registry.js +16 -0
  45. package/dist/lib/hosts/remote-cmd.js +2 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  48. package/dist/lib/observe-aliases.d.ts +30 -0
  49. package/dist/lib/observe-aliases.js +56 -0
  50. package/dist/lib/placement.d.ts +82 -0
  51. package/dist/lib/placement.js +188 -0
  52. package/dist/lib/profiles.d.ts +31 -9
  53. package/dist/lib/profiles.js +83 -13
  54. package/dist/lib/redact.js +4 -0
  55. package/dist/lib/rotate.d.ts +19 -4
  56. package/dist/lib/rotate.js +24 -1
  57. package/dist/lib/routines.d.ts +2 -0
  58. package/dist/lib/runner.d.ts +3 -0
  59. package/dist/lib/runner.js +90 -7
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/session/parse.d.ts +5 -1
  63. package/dist/lib/session/parse.js +23 -13
  64. package/dist/lib/session/prompt.js +5 -0
  65. package/dist/lib/session/render.d.ts +3 -0
  66. package/dist/lib/session/render.js +33 -10
  67. package/dist/lib/snapshot.d.ts +103 -0
  68. package/dist/lib/snapshot.js +99 -0
  69. package/dist/lib/startup/command-registry.d.ts +1 -0
  70. package/dist/lib/startup/command-registry.js +17 -1
  71. package/dist/lib/usage-refresh.d.ts +19 -11
  72. package/dist/lib/usage-refresh.js +75 -43
  73. package/dist/lib/usage.d.ts +12 -0
  74. package/dist/lib/usage.js +37 -6
  75. package/package.json +1 -1
@@ -11,9 +11,11 @@
11
11
  * native harness registry. The `agents profiles` tree stays unchanged.
12
12
  */
13
13
  import chalk from 'chalk';
14
- import { addProfile, ensureProviderToken } from './profiles.js';
15
- import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, } from '../lib/profiles.js';
16
- import { listPresets } from '../lib/profiles-presets.js';
14
+ import { addProfile, ensureProviderToken, applyFromSecrets } from './profiles.js';
15
+ import { isInteractiveTerminal } from './utils.js';
16
+ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, editProfile, renameProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, } from '../lib/profiles.js';
17
+ import { listPresets, getPreset } from '../lib/profiles-presets.js';
18
+ import { listBundles } from '../lib/secrets/bundles.js';
17
19
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName } from '../lib/agents.js';
18
20
  /** Short capability summary for a native harness — its supported run modes. */
19
21
  function nativeModes(id) {
@@ -58,7 +60,6 @@ export function buildFork(source, name, opts) {
58
60
  baseUrl: opts.baseUrl,
59
61
  provider: opts.authProvider,
60
62
  version: opts.version,
61
- label: opts.label,
62
63
  description: opts.description,
63
64
  });
64
65
  }
@@ -75,7 +76,6 @@ export function buildFork(source, name, opts) {
75
76
  baseUrl: opts.baseUrl,
76
77
  provider: opts.authProvider,
77
78
  authEnvVar: opts.authProvider ? authEnvKeyForHostOrThrow(host) : undefined,
78
- label: opts.label,
79
79
  description: opts.description ?? `Forked from ${host}: ${opts.model}`,
80
80
  });
81
81
  }
@@ -87,6 +87,215 @@ function authEnvKeyForHostOrThrow(host) {
87
87
  }
88
88
  return key;
89
89
  }
90
+ /** Map `agents harness edit` flags onto {@link ForkProfileOptions} — the same
91
+ * override shape `editProfile`/`forkProfile` apply. `--version ''` (unpin) is
92
+ * handled by the caller ({@link buildEdit}), not here: `forkProfile`'s own
93
+ * ternary treats an empty string as "no override" and would otherwise inherit
94
+ * the source's version instead of clearing it. */
95
+ export function buildEditOverrides(opts) {
96
+ const overrides = {};
97
+ if (opts.model !== undefined)
98
+ overrides.model = opts.model;
99
+ if (opts.baseUrl !== undefined)
100
+ overrides.baseUrl = opts.baseUrl;
101
+ if (opts.authProvider !== undefined)
102
+ overrides.provider = opts.authProvider;
103
+ if (opts.version)
104
+ overrides.version = opts.version;
105
+ if (opts.description !== undefined)
106
+ overrides.description = opts.description;
107
+ return overrides;
108
+ }
109
+ /** True when at least one recognized edit flag was given. */
110
+ export function hasEditFlags(opts) {
111
+ return (opts.model !== undefined ||
112
+ opts.baseUrl !== undefined ||
113
+ opts.authProvider !== undefined ||
114
+ opts.version !== undefined ||
115
+ opts.description !== undefined ||
116
+ opts.fallbackModel !== undefined ||
117
+ opts.fromSecrets !== undefined);
118
+ }
119
+ const EDIT_FLAGS_HELP = 'No changes given. Available flags: --model, --base-url, --auth-provider, --version, --description, --fallback-model, --from-secrets.';
120
+ /**
121
+ * Build the edited harness for `agents harness edit <name>` — the profile-shape
122
+ * transform only; the caller applies `--from-secrets`/`--auth-provider` (async
123
+ * keychain side effects) and persists with `writeProfile`. Mirrors
124
+ * {@link buildFork}'s split between a pure builder and the action's IO.
125
+ */
126
+ export function buildEdit(name, opts) {
127
+ if (!profileExists(name)) {
128
+ throw new Error(`Harness '${name}' not found. Create it first: agents harness add ${name} ...`);
129
+ }
130
+ if (!hasEditFlags(opts)) {
131
+ throw new Error(EDIT_FLAGS_HELP);
132
+ }
133
+ const source = readProfile(name);
134
+ const edited = editProfile(source, buildEditOverrides(opts));
135
+ if (opts.version === '')
136
+ delete edited.host.version;
137
+ if (opts.fallbackModel !== undefined) {
138
+ if (opts.fallbackModel === '')
139
+ delete edited.fallback_model;
140
+ else
141
+ edited.fallback_model = opts.fallbackModel;
142
+ }
143
+ return edited;
144
+ }
145
+ /** True when `agents harness fork` was given too little to proceed without the wizard. */
146
+ export function forkNeedsWizard(source, name, opts) {
147
+ if (!source || !name)
148
+ return true;
149
+ if (!profileExists(source) && resolveAgentName(source) && !opts.model)
150
+ return true;
151
+ return false;
152
+ }
153
+ /** True when `agents harness add` was given too little to proceed without the wizard.
154
+ * Mirrors {@link addProfile}'s own error condition in ./profiles.js so a bare
155
+ * `agents harness add <preset-name>` (no flags) still resolves via the preset
156
+ * fallback instead of being routed into the wizard. */
157
+ export function addNeedsWizard(name, opts) {
158
+ if (!name)
159
+ return true;
160
+ if (opts.preset)
161
+ return false;
162
+ if (opts.host && opts.model)
163
+ return false;
164
+ if (opts.host || opts.model)
165
+ return true;
166
+ return !getPreset(name);
167
+ }
168
+ /**
169
+ * Shared build+persist flow for a fork — used by `agents harness fork`'s
170
+ * flag-driven path AND by the wizard (both for `fork` and, when it falls back
171
+ * to the wizard, `add`), so a wizard run and a hand-written `fork` call build an
172
+ * identical profile.
173
+ */
174
+ async function runForkFlow(source, name, opts) {
175
+ validateProfileName(name);
176
+ if (profileExists(name) && !opts.force) {
177
+ throw new Error(`Harness '${name}' already exists. Use --force to overwrite.`);
178
+ }
179
+ // Build first so a bad source/flag combination fails before prompting for a
180
+ // key (or copying one from a bundle) the user would then have stored for
181
+ // nothing.
182
+ const forked = buildFork(source, name, opts);
183
+ if (opts.fromSecrets) {
184
+ // A fork's `auth` (when present) may just be inherited by reference from
185
+ // its source, not established for this harness itself -- never trust it
186
+ // as "safe to rotate" without an explicit --auth-provider.
187
+ await applyFromSecrets(forked, opts.fromSecrets, opts.authProvider, { allowInheritedAuth: false });
188
+ }
189
+ else if (opts.authProvider) {
190
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
191
+ }
192
+ writeProfile(forked);
193
+ console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
194
+ console.log(chalk.gray(`Try: agents run ${name} "hello"`));
195
+ }
196
+ /** The first `_MODEL`-suffixed env var in a preset's static env block, if any. */
197
+ function presetModel(preset) {
198
+ return Object.entries(preset.env).find(([k]) => k.endsWith('_MODEL'))?.[1];
199
+ }
200
+ /**
201
+ * Interactive `agents harness add`/`fork` wizard — runs when required info is
202
+ * missing and stdout is a TTY (see {@link forkNeedsWizard}, {@link addNeedsWizard}).
203
+ * Always resolves to the same `(source, name, opts)` shape {@link buildFork}
204
+ * accepts via {@link runForkFlow}, so a wizard run and a hand-written fork call
205
+ * build an identical profile.
206
+ */
207
+ async function runHarnessWizard() {
208
+ const { select, input } = await import('@inquirer/prompts');
209
+ const customNames = listProfiles().map((p) => p.name);
210
+ const source = await select({
211
+ message: 'Fork from',
212
+ choices: [
213
+ ...ALL_AGENT_IDS.map((id) => ({ name: `${AGENTS[id].name} ${chalk.gray('(native)')}`, value: id })),
214
+ ...customNames.map((n) => ({ name: `${n} ${chalk.gray('(custom harness)')}`, value: n })),
215
+ ],
216
+ });
217
+ const presets = listPresets();
218
+ const CUSTOM = '__custom__';
219
+ const presetChoice = await select({
220
+ message: 'Preset',
221
+ choices: [
222
+ ...presets.map((p) => ({ name: `${p.name} ${chalk.gray(p.description.slice(0, 60))}`, value: p.name })),
223
+ { name: 'Build custom (host + model + provider)', value: CUSTOM },
224
+ ],
225
+ });
226
+ let model;
227
+ let baseUrl;
228
+ let authProvider;
229
+ let defaultName = source;
230
+ if (presetChoice === CUSTOM) {
231
+ model = await input({ message: 'Model id' });
232
+ const NO_AUTH = '__none__';
233
+ const providers = [...new Set(presets.map((p) => p.provider))];
234
+ const providerChoice = await select({
235
+ message: 'Provider',
236
+ choices: [
237
+ ...providers.map((p) => ({ name: p, value: p })),
238
+ { name: 'no auth / host manages its own login', value: NO_AUTH },
239
+ ],
240
+ });
241
+ authProvider = providerChoice === NO_AUTH ? undefined : providerChoice;
242
+ const baseUrlInput = await input({ message: 'Base URL (optional)', default: '' });
243
+ baseUrl = baseUrlInput || undefined;
244
+ }
245
+ else {
246
+ const preset = getPreset(presetChoice);
247
+ model = presetModel(preset);
248
+ baseUrl = preset.env.ANTHROPIC_BASE_URL || preset.env.OPENAI_BASE_URL;
249
+ authProvider = preset.authOptional ? undefined : preset.provider;
250
+ // Pre-fill with the preset's own name (e.g. 'deepseek'), not a model
251
+ // detail, so users aren't nudged toward baking one into the identity name.
252
+ defaultName = preset.name;
253
+ }
254
+ const name = await input({
255
+ message: 'Harness name',
256
+ default: defaultName,
257
+ validate: (v) => {
258
+ try {
259
+ validateProfileName(v);
260
+ return true;
261
+ }
262
+ catch (err) {
263
+ return err.message;
264
+ }
265
+ },
266
+ });
267
+ const opts = { model, baseUrl, authProvider };
268
+ if (authProvider) {
269
+ const bundles = listBundles();
270
+ const TYPE_NOW = 'type';
271
+ const FROM_SECRETS = 'secrets';
272
+ const keySource = bundles.length > 0
273
+ ? await select({
274
+ message: `How should '${authProvider}' get its key?`,
275
+ choices: [
276
+ { name: 'Type a key now', value: TYPE_NOW },
277
+ { name: 'Use an existing agents secrets bundle', value: FROM_SECRETS },
278
+ ],
279
+ })
280
+ : TYPE_NOW;
281
+ if (keySource === FROM_SECRETS) {
282
+ const bundleName = await select({
283
+ message: 'Bundle',
284
+ choices: bundles.map((b) => ({
285
+ name: b.description ? `${b.name} ${chalk.gray(b.description)}` : b.name,
286
+ value: b.name,
287
+ })),
288
+ });
289
+ const bundle = bundles.find((b) => b.name === bundleName);
290
+ const keys = Object.keys(bundle.vars);
291
+ const key = keys.length === 1
292
+ ? keys[0]
293
+ : await select({ message: 'Key', choices: keys.map((k) => ({ name: k, value: k })) });
294
+ opts.fromSecrets = `${bundleName}:${key}`;
295
+ }
296
+ }
297
+ return { source, name, opts };
298
+ }
90
299
  export function registerHarnessCommands(program) {
91
300
  const cmd = program
92
301
  .command('harness')
@@ -112,6 +321,13 @@ Examples:
112
321
  # Per-run model override still wins
113
322
  agents run spark --model opencode/big-pickle "quick pass"
114
323
 
324
+ # Edit a harness in place, or give it a new name
325
+ agents harness edit deepseek --fallback-model deepseek/deepseek-chat-v3
326
+ agents harness rename deepseek deepseek-classic
327
+
328
+ # No args, in an interactive terminal: a wizard walks you through host, model, provider, key
329
+ agents harness add
330
+
115
331
  # See custom harnesses, addable presets, and native harnesses
116
332
  agents harness list
117
333
 
@@ -121,18 +337,27 @@ Examples:
121
337
  # Custom harnesses are just profiles — this also works via 'agents profiles'
122
338
  `);
123
339
  cmd
124
- .command('add <name>')
125
- .description('Create a custom harness from a host + model (or apply a built-in preset).')
340
+ .command('add [name]')
341
+ .description('Create a custom harness from a host + model (or apply a built-in preset). Omit flags in a terminal for the interactive wizard.')
126
342
  .option('--host <agent>', 'Host CLI to run under (opencode, claude, codex, grok, antigravity, ...) — pair with --model')
127
343
  .option('--model <id>', 'Model id to pin on the host (e.g., meta/muse-spark-1.1) — pair with --host')
128
344
  .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
129
345
  .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider (for private endpoints)')
130
346
  .option('--preset <preset>', 'Apply a built-in preset instead of --host/--model')
131
347
  .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
348
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
132
349
  .option('--key-stdin', 'Read API key from stdin instead of prompting (for scripts/CI)')
133
350
  .option('--force', 'Overwrite an existing harness with the same name')
134
351
  .action(async (name, opts) => {
135
352
  try {
353
+ if (addNeedsWizard(name, opts)) {
354
+ if (!isInteractiveTerminal()) {
355
+ throw new Error("'agents harness add' needs --preset or --host + --model (or a name and an interactive terminal for the wizard).");
356
+ }
357
+ const wiz = await runHarnessWizard();
358
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
359
+ return;
360
+ }
136
361
  await addProfile(name, opts, 'Harness');
137
362
  }
138
363
  catch (err) {
@@ -141,14 +366,14 @@ Examples:
141
366
  }
142
367
  });
143
368
  cmd
144
- .command('fork <source> <name>')
145
- .description('Fork a native harness (claude, opencode, ...) or an existing custom one into a new named harness.')
369
+ .command('fork [source] [name]')
370
+ .description('Fork a native harness (claude, opencode, ...) or an existing custom one into a new named harness. Omit args in a terminal for the interactive wizard.')
146
371
  .option('--model <id>', 'Model to pin on the fork (required when forking a native harness)')
147
372
  .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
148
373
  .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider')
149
374
  .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
150
- .option('--label <text>', 'Human-facing name shown by `agents view` (defaults to <name>)')
151
375
  .option('--description <text>', 'One-line description')
376
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
152
377
  .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
153
378
  .option('--force', 'Overwrite an existing harness with the same name')
154
379
  .addHelpText('after', `
@@ -161,21 +386,92 @@ Examples:
161
386
 
162
387
  # Copy an existing harness and swap only the model
163
388
  agents harness fork deepseek deepseek-chat --model deepseek/deepseek-chat-v3
389
+
390
+ # Copy a key out of an existing secrets bundle instead of typing it
391
+ agents harness fork claude corp --model gpt-x --auth-provider corp --from-secrets prod:OPENROUTER_KEY
392
+
393
+ # No args, in an interactive terminal: walks through source, preset/model, name, key
394
+ agents harness fork
164
395
  `)
165
396
  .action(async (source, name, opts) => {
166
397
  try {
167
- validateProfileName(name);
168
- if (profileExists(name) && !opts.force) {
169
- throw new Error(`Harness '${name}' already exists. Use --force to overwrite.`);
398
+ if (forkNeedsWizard(source, name, opts)) {
399
+ if (!isInteractiveTerminal()) {
400
+ throw new Error("'agents harness fork' needs <source> and <name> (or an interactive terminal for the wizard).");
401
+ }
402
+ const wiz = await runHarnessWizard();
403
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
404
+ return;
170
405
  }
171
- // Build first so a bad source/flag combination fails before prompting
172
- // for a key the user would then have stored for nothing.
173
- const forked = buildFork(source, name, opts);
174
- if (opts.authProvider)
406
+ await runForkFlow(source, name, opts);
407
+ }
408
+ catch (err) {
409
+ console.error(chalk.red(err.message));
410
+ process.exit(1);
411
+ }
412
+ });
413
+ cmd
414
+ .command('edit <name>')
415
+ .description('Edit an existing custom harness in place — model, endpoint, auth, version, description, fallback.')
416
+ .option('--model <id>', 'Swap the pinned model')
417
+ .option('--base-url <url>', 'Swap the custom endpoint base URL')
418
+ .option('--auth-provider <provider>', 'Repoint auth at a different provider (keychain-backed)')
419
+ .option('--version <version>', 'Re-pin the host CLI version (pass an empty string to unpin)')
420
+ .option('--description <text>', 'Update the one-line description')
421
+ .option('--fallback-model <id>', 'Secondary model retried on the same host on a rate limit (pass an empty string to clear it)')
422
+ .option('--from-secrets <bundle>[:<key>]', "Copy a value from an agents secrets bundle into this harness's own keychain item, once (not a live link)")
423
+ .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
424
+ .addHelpText('after', `
425
+ Examples:
426
+ # Swap the pinned model
427
+ agents harness edit deepseek --model deepseek/deepseek-v3.2
428
+
429
+ # Repoint auth at a different provider and re-enter the key
430
+ agents harness edit corp --auth-provider corp2
431
+
432
+ # Unpin the host CLI version
433
+ agents harness edit spark --version ""
434
+
435
+ # Add a same-host fallback model for rate-limit retries
436
+ agents harness edit deepseek --fallback-model deepseek/deepseek-chat-v3
437
+
438
+ # Copy a key out of an existing secrets bundle instead of typing it
439
+ agents harness edit corp --from-secrets prod:OPENROUTER_KEY
440
+ `)
441
+ .action(async (name, opts) => {
442
+ try {
443
+ const edited = buildEdit(name, opts);
444
+ if (opts.fromSecrets) {
445
+ await applyFromSecrets(edited, opts.fromSecrets, opts.authProvider);
446
+ }
447
+ else if (opts.authProvider) {
175
448
  await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
176
- writeProfile(forked);
177
- console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
178
- console.log(chalk.gray(`Try: agents run ${name} "hello"`));
449
+ }
450
+ writeProfile(edited);
451
+ console.log(chalk.green(`Harness '${name}' updated.`));
452
+ console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
453
+ }
454
+ catch (err) {
455
+ console.error(chalk.red(err.message));
456
+ process.exit(1);
457
+ }
458
+ });
459
+ cmd
460
+ .command('rename <old-name> <new-name>')
461
+ .description('Rename a custom harness (updates forkedFrom lineage on any harness forked from it). Errors on a name collision.')
462
+ .addHelpText('after', `
463
+ Examples:
464
+ # Rename 'spark' to 'muse'
465
+ agents harness rename spark muse
466
+
467
+ # Rename then run under the new name
468
+ agents harness rename deepseek ds && agents run ds "hello"
469
+ `)
470
+ .action((oldName, newName) => {
471
+ try {
472
+ renameProfile(oldName, newName);
473
+ console.log(chalk.green(`Harness '${oldName}' renamed to '${newName}'.`));
474
+ console.log(chalk.gray(`Try: agents run ${newName} "hello"`));
179
475
  }
180
476
  catch (err) {
181
477
  console.error(chalk.red(err.message));
@@ -315,9 +315,9 @@ export function registerMonitorsCommands(program) {
315
315
  .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
316
316
  .option('--webhook-out <url>', 'POST the event to this URL')
317
317
  // PLACEMENT / hygiene
318
- .option('--device <name>', 'OWNER device — the single machine that evaluates + fires (exactly-once)')
318
+ .option('--device <name>', 'OWNER (not body placement) — the single machine that evaluates + fires (exactly-once). See docs/00-concepts.md#placement.')
319
319
  .option('--devices <list>', 'Allowlist (comma-separated): each device fires independently')
320
- .option('--run-on <host>', 'Execute the ACTION on this machine over SSH (placement)')
320
+ .option('--run-on <host>', 'BODY placement — execute the ACTION on this machine over SSH (same idea as run --where device:<host>)')
321
321
  .option('--rate-limit <spec>', 'Auto-pause if it fires more than N/<interval> (e.g. 5/1m)')
322
322
  .option('--disabled', 'Create the monitor paused (enable later with resume)')
323
323
  .action(async (nameOrPath, options) => {
@@ -28,7 +28,49 @@ export interface AddProfileOptions {
28
28
  version?: string;
29
29
  keyStdin?: boolean;
30
30
  force?: boolean;
31
+ /** `<bundle>` or `<bundle>:<key>` — see {@link applyFromSecrets}. */
32
+ fromSecrets?: string;
31
33
  }
34
+ /**
35
+ * Copy a value from an `agents secrets` bundle into a profile's own keychain
36
+ * item — a one-time copy, not a live link. `spec` is `<bundle>` or
37
+ * `<bundle>:<key>`; the key is required only when the bundle has more than one.
38
+ *
39
+ * Reads the bundle value via {@link getKeychainToken} — this pops Touch ID on
40
+ * macOS once, for that bundle-namespaced item (`agents-cli.secrets.*`, gated by
41
+ * `keychainItemRequiresUserPresence` in `../lib/secrets/index.ts`) — then writes
42
+ * it to `keychainItemName(provider)`, a plain `agents-cli.<provider>.token`
43
+ * item. That item matches neither the `agents-cli.secrets.` nor
44
+ * `agents-cli.bundles.` prefix, so every later read of the harness's own key is
45
+ * silent — no repeat Touch ID prompt.
46
+ *
47
+ * Provider precedence: an explicit `--auth-provider` on the same call always
48
+ * wins (freshest intent); otherwise, only when the profile already has a real
49
+ * auth binding (`profile.auth` is set), its own `provider` (so editing an
50
+ * already-provisioned harness rotates its existing key without repeating
51
+ * `--auth-provider`); otherwise the bundle's own name. The `profile.auth`
52
+ * gate matters because `profileFromHostModel`/`forkProfile` default
53
+ * `profile.provider` to the *host* id (e.g. `claude`) even when no auth is
54
+ * attached yet — trusting that default here would silently overwrite the
55
+ * host's own keychain item (`agents-cli.claude.token`) on a bare `--host
56
+ * --model --from-secrets` add with no `--auth-provider`.
57
+ *
58
+ * Attaches `profile.auth` when the profile has none yet (a bare `--host
59
+ * --model` harness, or a native-host fork with no prior auth binding).
60
+ *
61
+ * `allowInheritedAuth` (default `true`) gates the "reuse `profile.auth`'s own
62
+ * provider" branch above. It must be `false` when `profile` came from
63
+ * **forking** an already-provisioned harness: `forkProfile` copies `auth` by
64
+ * reference from the source when no `--auth-provider` override is given, so
65
+ * `profile.auth` being set there means "the SOURCE harness's binding", not
66
+ * "this harness's own" — trusting it would silently overwrite the source's
67
+ * shared keychain item. Editing a harness's own already-established auth is
68
+ * the one case where reuse is genuinely correct, so callers on that path keep
69
+ * the default.
70
+ */
71
+ export declare function applyFromSecrets(profile: Profile, spec: string, explicitAuthProvider?: string, opts?: {
72
+ allowInheritedAuth?: boolean;
73
+ }): Promise<void>;
32
74
  /**
33
75
  * Create a profile ("custom harness"). Two paths:
34
76
  * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
@@ -11,7 +11,9 @@ import { readStdinSync } from '../lib/format.js';
11
11
  import { spawn } from 'child_process';
12
12
  import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, profileFromHostModel, baseUrlEnvKeyForHost, authEnvKeyForHost, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
13
13
  import { getPreset, listPresets, expandPreset } from '../lib/profiles-presets.js';
14
- import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, } from '../lib/secrets/profiles.js';
14
+ import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, getKeychainToken, } from '../lib/secrets/profiles.js';
15
+ import { parseBundleValue, secretsKeychainItem } from '../lib/secrets/index.js';
16
+ import { readBundle } from '../lib/secrets/bundles.js';
15
17
  import { isInteractiveTerminal } from './utils.js';
16
18
  import { getAgentsInvocation } from '../lib/daemon.js';
17
19
  import { getActiveResourceProfileName, getResourceProfilePreset, listResourceProfileNames, setActiveResourceProfile, upsertResourceProfilePreset, validateResourceProfileName, } from '../lib/resource-profiles.js';
@@ -66,6 +68,87 @@ export async function ensureProviderToken(provider, signupUrl, fromStdin) {
66
68
  setKeychainToken(item, token);
67
69
  console.log(chalk.green(`Stored in keychain: ${item}`));
68
70
  }
71
+ /**
72
+ * Copy a value from an `agents secrets` bundle into a profile's own keychain
73
+ * item — a one-time copy, not a live link. `spec` is `<bundle>` or
74
+ * `<bundle>:<key>`; the key is required only when the bundle has more than one.
75
+ *
76
+ * Reads the bundle value via {@link getKeychainToken} — this pops Touch ID on
77
+ * macOS once, for that bundle-namespaced item (`agents-cli.secrets.*`, gated by
78
+ * `keychainItemRequiresUserPresence` in `../lib/secrets/index.ts`) — then writes
79
+ * it to `keychainItemName(provider)`, a plain `agents-cli.<provider>.token`
80
+ * item. That item matches neither the `agents-cli.secrets.` nor
81
+ * `agents-cli.bundles.` prefix, so every later read of the harness's own key is
82
+ * silent — no repeat Touch ID prompt.
83
+ *
84
+ * Provider precedence: an explicit `--auth-provider` on the same call always
85
+ * wins (freshest intent); otherwise, only when the profile already has a real
86
+ * auth binding (`profile.auth` is set), its own `provider` (so editing an
87
+ * already-provisioned harness rotates its existing key without repeating
88
+ * `--auth-provider`); otherwise the bundle's own name. The `profile.auth`
89
+ * gate matters because `profileFromHostModel`/`forkProfile` default
90
+ * `profile.provider` to the *host* id (e.g. `claude`) even when no auth is
91
+ * attached yet — trusting that default here would silently overwrite the
92
+ * host's own keychain item (`agents-cli.claude.token`) on a bare `--host
93
+ * --model --from-secrets` add with no `--auth-provider`.
94
+ *
95
+ * Attaches `profile.auth` when the profile has none yet (a bare `--host
96
+ * --model` harness, or a native-host fork with no prior auth binding).
97
+ *
98
+ * `allowInheritedAuth` (default `true`) gates the "reuse `profile.auth`'s own
99
+ * provider" branch above. It must be `false` when `profile` came from
100
+ * **forking** an already-provisioned harness: `forkProfile` copies `auth` by
101
+ * reference from the source when no `--auth-provider` override is given, so
102
+ * `profile.auth` being set there means "the SOURCE harness's binding", not
103
+ * "this harness's own" — trusting it would silently overwrite the source's
104
+ * shared keychain item. Editing a harness's own already-established auth is
105
+ * the one case where reuse is genuinely correct, so callers on that path keep
106
+ * the default.
107
+ */
108
+ export async function applyFromSecrets(profile, spec, explicitAuthProvider, opts) {
109
+ const sep = spec.indexOf(':');
110
+ const bundleName = sep === -1 ? spec : spec.slice(0, sep);
111
+ const requestedKey = sep === -1 ? undefined : spec.slice(sep + 1);
112
+ const bundle = readBundle(bundleName);
113
+ const keys = Object.keys(bundle.vars);
114
+ const key = requestedKey ?? (keys.length === 1 ? keys[0] : undefined);
115
+ if (!key) {
116
+ throw new Error(keys.length === 0
117
+ ? `Bundle '${bundleName}' has no keys.`
118
+ : `Bundle '${bundleName}' has ${keys.length} keys (${keys.join(', ')}); pick one with --from-secrets ${bundleName}:<key>.`);
119
+ }
120
+ if (!(key in bundle.vars)) {
121
+ throw new Error(`Bundle '${bundleName}' has no key '${key}'. Available: ${keys.join(', ') || '(none)'}.`);
122
+ }
123
+ const parsed = parseBundleValue(bundle.vars[key]);
124
+ let value;
125
+ if ('literal' in parsed) {
126
+ value = parsed.literal;
127
+ }
128
+ else if (parsed.ref.provider === 'keychain') {
129
+ value = getKeychainToken(secretsKeychainItem(bundle.name, parsed.ref.value));
130
+ }
131
+ else {
132
+ throw new Error(`Bundle '${bundleName}' key '${key}' is a '${parsed.ref.provider}:' reference, not a keychain-backed secret — --from-secrets only copies keychain-backed values.`);
133
+ }
134
+ if (profile.auth && !explicitAuthProvider && opts?.allowInheritedAuth === false) {
135
+ throw new Error(`Harness '${profile.name}' inherited its auth binding from the harness it was forked from ` +
136
+ `(provider '${profile.provider}') — pass --auth-provider <name> explicitly with --from-secrets ` +
137
+ `on a fork, so it never silently overwrites the source harness's shared keychain item.`);
138
+ }
139
+ const provider = explicitAuthProvider || (profile.auth ? profile.provider : undefined) || bundleName;
140
+ const item = keychainItemName(provider);
141
+ setKeychainToken(item, value);
142
+ if (!profile.auth) {
143
+ const envVar = authEnvKeyForHost(profile.host.agent);
144
+ if (!envVar) {
145
+ throw new Error(`Host '${profile.host.agent}' has no known auth env var; --from-secrets cannot attach auth to this harness.`);
146
+ }
147
+ profile.provider = provider;
148
+ profile.auth = { envVar, keychainItem: item };
149
+ profile.authOptional = false;
150
+ }
151
+ }
69
152
  /**
70
153
  * Create a profile ("custom harness"). Two paths:
71
154
  * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
@@ -98,7 +181,8 @@ export async function addProfile(name, opts, label = 'Profile') {
98
181
  throw new Error(`--auth-provider is set but host '${host}' has no known auth env var. Use a preset or a hand-written profile YAML.`);
99
182
  }
100
183
  authEnvVar = key;
101
- await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
184
+ if (!opts.fromSecrets)
185
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
102
186
  }
103
187
  const profile = profileFromHostModel(name, host, opts.model, {
104
188
  version: opts.version,
@@ -106,6 +190,8 @@ export async function addProfile(name, opts, label = 'Profile') {
106
190
  provider: opts.authProvider,
107
191
  authEnvVar,
108
192
  });
193
+ if (opts.fromSecrets)
194
+ await applyFromSecrets(profile, opts.fromSecrets, opts.authProvider);
109
195
  writeProfile(profile);
110
196
  console.log(chalk.green(`${label} '${name}' added — ${host} + ${opts.model}.`));
111
197
  console.log(chalk.gray(`Try: agents run ${name} "hello"`));
@@ -118,10 +204,12 @@ export async function addProfile(name, opts, label = 'Profile') {
118
204
  throw new Error(`No preset '${presetName}'.\nAvailable presets: ${listPresets().map((p) => p.name).join(', ')}\n` +
119
205
  'Or build a custom harness: --host <agent> --model <id>.');
120
206
  }
121
- if (!preset.authOptional) {
207
+ if (!opts.fromSecrets && !preset.authOptional) {
122
208
  await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
123
209
  }
124
210
  const profile = profileFromPreset(name, preset, opts.version);
211
+ if (opts.fromSecrets)
212
+ await applyFromSecrets(profile, opts.fromSecrets, opts.authProvider);
125
213
  writeProfile(profile);
126
214
  console.log(chalk.green(`${label} '${name}' added.`));
127
215
  console.log(chalk.gray(`Try: agents run ${name} "hello"`));
@@ -628,8 +628,8 @@ export function registerRoutinesCommands(program) {
628
628
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
629
629
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
630
630
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
631
- .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Sets hostStrategy=host. Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
632
- .option('--placement <strategy>', `Where the job body runs: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Not the same as --host (which manages routines on a remote machine).`)
631
+ .option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host. Not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given. Same model as agents run --where device:<name> (docs/00-concepts.md#placement).')
632
+ .option('--placement <strategy>', `BODY placement strategy: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Maps to the shared Placement model (local|device|fleet|cloud). Not the same as --host (which manages routines on a remote machine).`)
633
633
  .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
634
634
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
635
635
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
@@ -36,6 +36,8 @@ function disabledReason(candidate) {
36
36
  return undefined;
37
37
  if (readiness.reason === 'signed_out')
38
38
  return 'logged out';
39
+ if (readiness.reason === 'revoked')
40
+ return 'needs re-login';
39
41
  if (readiness.reason === 'out_of_credits')
40
42
  return 'out of credits';
41
43
  const windows = candidate.usageSnapshot?.windows ?? [];
@@ -11,7 +11,7 @@ import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
12
  import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
13
13
  import { safeTeamText } from '../lib/session/team-filter.js';
14
- import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
+ import { cleanSessionPrompt, extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
15
15
  import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
16
16
  import { linearIssueUrl } from '../lib/session/linear.js';
17
17
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
@@ -383,7 +383,7 @@ function formatCompactPreview(events, session) {
383
383
  const knownToolUsage = session.usedBrowser !== undefined;
384
384
  for (const event of events) {
385
385
  if (event.type === 'message') {
386
- if (event.role === 'user' && !firstUser && event.content) {
386
+ if (event.role === 'user' && !event._synthetic && !firstUser && event.content) {
387
387
  if (!SYSTEM_MESSAGE_PATTERNS.some(p => p.test(event.content))) {
388
388
  firstUser = event.content;
389
389
  }
@@ -443,7 +443,7 @@ function formatCompactPreview(events, session) {
443
443
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(first.trim(), termWidth - 12)));
444
444
  }
445
445
  }
446
- else if (session.topic) {
446
+ else if (session.topic && !isSyntheticUserMessage(session.topic)) {
447
447
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), termWidth - 12)));
448
448
  }
449
449
  // Compact checklist: ✓done/total · current step (RUSH-2045).