@phnx-labs/agents-cli 1.20.52 → 1.20.53

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 (107) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +10 -1
  18. package/dist/commands/worktree.js +4 -2
  19. package/dist/index.js +16 -21
  20. package/dist/lib/agents.js +249 -17
  21. package/dist/lib/answer-router.d.ts +75 -0
  22. package/dist/lib/answer-router.js +149 -0
  23. package/dist/lib/ask-classifier.d.ts +71 -0
  24. package/dist/lib/ask-classifier.js +197 -0
  25. package/dist/lib/cloud/antigravity.d.ts +0 -2
  26. package/dist/lib/cloud/antigravity.js +2 -17
  27. package/dist/lib/cloud/codex.js +3 -18
  28. package/dist/lib/cloud/rush.js +3 -15
  29. package/dist/lib/cloud/stream.js +2 -0
  30. package/dist/lib/cloud/types.d.ts +21 -0
  31. package/dist/lib/cloud/types.js +81 -0
  32. package/dist/lib/crabbox/cli.d.ts +1 -1
  33. package/dist/lib/crabbox/cli.js +12 -2
  34. package/dist/lib/crabbox/lease.d.ts +13 -0
  35. package/dist/lib/crabbox/lease.js +11 -2
  36. package/dist/lib/crabbox/progress.d.ts +62 -0
  37. package/dist/lib/crabbox/progress.js +129 -0
  38. package/dist/lib/events.js +4 -1
  39. package/dist/lib/exec.js +19 -1
  40. package/dist/lib/feed-outcome.d.ts +101 -0
  41. package/dist/lib/feed-outcome.js +244 -0
  42. package/dist/lib/feed-policy.d.ts +30 -0
  43. package/dist/lib/feed-policy.js +133 -0
  44. package/dist/lib/feed.d.ts +127 -3
  45. package/dist/lib/feed.js +416 -40
  46. package/dist/lib/git.d.ts +17 -1
  47. package/dist/lib/git.js +20 -1
  48. package/dist/lib/hooks.js +522 -12
  49. package/dist/lib/hosts/passthrough.d.ts +3 -3
  50. package/dist/lib/hosts/passthrough.js +3 -4
  51. package/dist/lib/mailbox-gc.d.ts +22 -0
  52. package/dist/lib/mailbox-gc.js +161 -0
  53. package/dist/lib/mailbox.d.ts +26 -2
  54. package/dist/lib/mailbox.js +80 -5
  55. package/dist/lib/mcp.js +82 -0
  56. package/dist/lib/memory.d.ts +55 -0
  57. package/dist/lib/memory.js +274 -0
  58. package/dist/lib/notify.d.ts +16 -0
  59. package/dist/lib/notify.js +61 -0
  60. package/dist/lib/operator.d.ts +26 -0
  61. package/dist/lib/operator.js +107 -0
  62. package/dist/lib/plugins.d.ts +35 -0
  63. package/dist/lib/plugins.js +217 -0
  64. package/dist/lib/remote-agents-json.d.ts +14 -0
  65. package/dist/lib/remote-agents-json.js +94 -0
  66. package/dist/lib/resources/mcp.js +44 -0
  67. package/dist/lib/resources/memory.d.ts +15 -0
  68. package/dist/lib/resources/memory.js +46 -0
  69. package/dist/lib/resources/types.d.ts +2 -2
  70. package/dist/lib/runner.d.ts +43 -0
  71. package/dist/lib/runner.js +323 -74
  72. package/dist/lib/sandbox.js +6 -0
  73. package/dist/lib/secrets/bundles.js +38 -13
  74. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  75. package/dist/lib/secrets/icloud-import.js +37 -7
  76. package/dist/lib/secrets/index.d.ts +106 -2
  77. package/dist/lib/secrets/index.js +603 -28
  78. package/dist/lib/session/active.d.ts +18 -0
  79. package/dist/lib/session/active.js +47 -17
  80. package/dist/lib/session/db.d.ts +9 -1
  81. package/dist/lib/session/db.js +18 -3
  82. package/dist/lib/session/discover.d.ts +13 -0
  83. package/dist/lib/session/discover.js +31 -0
  84. package/dist/lib/session/parse.d.ts +8 -0
  85. package/dist/lib/session/parse.js +42 -21
  86. package/dist/lib/session/remote-active.js +8 -89
  87. package/dist/lib/session/state.d.ts +11 -0
  88. package/dist/lib/session/state.js +37 -0
  89. package/dist/lib/session/tail.d.ts +23 -4
  90. package/dist/lib/session/tail.js +34 -16
  91. package/dist/lib/session/throughput.d.ts +30 -0
  92. package/dist/lib/session/throughput.js +86 -0
  93. package/dist/lib/shim-heal.d.ts +12 -3
  94. package/dist/lib/shim-heal.js +12 -6
  95. package/dist/lib/staleness/detectors/subagents.js +57 -3
  96. package/dist/lib/staleness/writers/hooks.js +7 -3
  97. package/dist/lib/staleness/writers/subagents.js +37 -6
  98. package/dist/lib/startup/command-registry.d.ts +1 -0
  99. package/dist/lib/startup/command-registry.js +2 -0
  100. package/dist/lib/subagents.d.ts +52 -0
  101. package/dist/lib/subagents.js +315 -12
  102. package/dist/lib/teams/worktree.d.ts +8 -0
  103. package/dist/lib/teams/worktree.js +8 -0
  104. package/dist/lib/types.d.ts +10 -2
  105. package/dist/lib/versions.js +38 -48
  106. package/package.json +4 -3
  107. package/scripts/postinstall.js +61 -1
@@ -1,15 +1,119 @@
1
1
  import chalk from 'chalk';
2
+ import { spawn } from 'child_process';
2
3
  import { die } from '../lib/format.js';
3
4
  import { getActiveSessions } from '../lib/session/active.js';
4
5
  import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
5
6
  import { resolveProvider } from '../lib/cloud/registry.js';
6
7
  import { mailboxDir, enqueue } from '../lib/mailbox.js';
7
- import { resolveMessageTarget } from '../lib/mailbox-target.js';
8
+ import { resolveMessageTarget, mailboxIdForActiveSession } from '../lib/mailbox-target.js';
9
+ import { blockIdForSession, listBlocks, readBlock, recordAnswer, recordMessageReceipt, } from '../lib/feed.js';
10
+ import { verifyOperatorIdentity } from '../lib/operator.js';
11
+ import { resolveAnswerRoute, resumeArgv, } from '../lib/answer-router.js';
12
+ import { injectIntoTerminal } from '../lib/terminal/inject.js';
13
+ /** Find the still-open block addressed to `mailboxId`, if any. */
14
+ function findOpenBlockForMailbox(mailboxId) {
15
+ // Fast path: the mailbox id is usually the session id, so the block id is
16
+ // directly derivable. This avoids scanning the whole feed store.
17
+ const direct = readBlock(blockIdForSession(mailboxId));
18
+ if (direct && direct.mailboxId === mailboxId)
19
+ return direct;
20
+ // Fallback: scan (agentId-based mailbox ids, rare).
21
+ return listBlocks().find((b) => b.mailboxId === mailboxId);
22
+ }
23
+ /** Live session whose mailbox id equals `mailboxId`. */
24
+ function findSessionForMailbox(mailboxId, sessions) {
25
+ return sessions.find((s) => mailboxIdForActiveSession(s) === mailboxId);
26
+ }
27
+ /** Claim first-answer-wins on the open block; dies if already answered / unauthorized. */
28
+ function claimBlockAnswer(block, opts) {
29
+ if (!block)
30
+ return;
31
+ const operatorId = opts.as;
32
+ // High-consequence answers require env-proven identity (AGENTS_OPERATOR_ID),
33
+ // not merely a caller-supplied known --as id (RUSH-1619).
34
+ const verified = verifyOperatorIdentity(operatorId);
35
+ const claim = recordAnswer(block.blockId, {
36
+ answeredBy: opts.from,
37
+ answeredFrom: opts.surface || 'cli',
38
+ operatorId,
39
+ verified,
40
+ });
41
+ if (!claim.ok) {
42
+ if ('unauthorized' in claim) {
43
+ die(`Not authorized: ${claim.reason}`);
44
+ }
45
+ const who = claim.existing.answeredFrom + (claim.existing.answeredBy ? ` (${claim.existing.answeredBy})` : '');
46
+ die(`This question was already answered by ${who}.`);
47
+ }
48
+ }
49
+ async function deliverViaMailbox(mailboxId, text, block, opts) {
50
+ const msgId = enqueue(mailboxDir(mailboxId), {
51
+ to: mailboxId,
52
+ text,
53
+ from: opts.from,
54
+ blockId: block?.blockId,
55
+ });
56
+ if (block) {
57
+ recordMessageReceipt(block.blockId, {
58
+ msgId,
59
+ status: 'queued',
60
+ at: new Date().toISOString(),
61
+ from: opts.from,
62
+ });
63
+ console.log(chalk.green(`Queued message ${msgId} for ${mailboxId}. `) +
64
+ chalk.dim(`Answer tied to ${block.blockId}; the agent will see it at its next tool call.`));
65
+ }
66
+ else {
67
+ console.log(chalk.green(`Queued message ${msgId} for ${mailboxId}. `) +
68
+ chalk.dim('The agent will see it at its next tool call.'));
69
+ }
70
+ }
71
+ async function deliverViaInject(route, mailboxId) {
72
+ if (!route.inject || route.payload == null) {
73
+ die(`Internal error: inject route missing target/payload for ${mailboxId}.`);
74
+ }
75
+ const result = await injectIntoTerminal(route.inject, route.payload, {
76
+ // Digit selection and free text both need Enter to submit the TUI choice.
77
+ enter: true,
78
+ // Digit+Enter as two writes is safer for Ink TUI.
79
+ combined: false,
80
+ });
81
+ if (!result.ok) {
82
+ die(`Failed to inject answer into ${route.inject.backend}: ${result.error ?? 'unknown error'}`);
83
+ }
84
+ console.log(chalk.green(`Answered ${mailboxId} via ${route.inject.backend}. `) +
85
+ chalk.dim(route.reason));
86
+ }
87
+ async function deliverViaResume(route, mailboxId) {
88
+ if (route.kind !== 'resume') {
89
+ die(`Internal error: resume route incomplete for ${mailboxId}.`);
90
+ }
91
+ const argv = resumeArgv(route);
92
+ // Spawn the same agents binary the user invoked (process.argv[1]) so version
93
+ // pins and wrappers stay consistent. Detach so the resume can take over a TTY
94
+ // when interactive; for feed answers we pass the prompt non-interactively.
95
+ const bin = process.argv[1] ?? 'agents';
96
+ const child = spawn(process.execPath, [bin, ...argv], {
97
+ stdio: 'inherit',
98
+ env: process.env,
99
+ });
100
+ const code = await new Promise((resolve) => {
101
+ child.on('exit', (c) => resolve(c ?? 1));
102
+ child.on('error', () => resolve(1));
103
+ });
104
+ if (code !== 0) {
105
+ die(`Resume of ${mailboxId} exited with code ${code}. Tried: agents ${argv.join(' ')}`);
106
+ }
107
+ console.log(chalk.green(`Resumed ${mailboxId} with answer. `) +
108
+ chalk.dim(route.reason));
109
+ }
8
110
  export function registerMessageCommand(program) {
9
111
  program
10
112
  .command('message <target> <text>')
11
- .description('Send a message to a running agent (delivered at its next tool call) or a cloud task.')
113
+ .description('Send a message to a running or parked agent (mailbox / PTY-select / resume by runtime).')
12
114
  .option('--from <who>', 'Label recorded as the sender of this message')
115
+ .option('--as <operator>', 'Verified operator id answering a high-consequence block')
116
+ .option('--surface <surface>', 'Surface that is sending this answer (feed, terminal, etc.)', 'cli')
13
117
  .action(async (target, text, opts) => {
14
118
  if (!target.trim()) {
15
119
  die('Target must be a session/agent id or cloud task id. Run `agents sessions --active` to list running agents.');
@@ -32,9 +136,40 @@ export function registerMessageCommand(program) {
32
136
  }
33
137
  case 'local': {
34
138
  try {
35
- const msgId = enqueue(mailboxDir(res.id), { to: res.id, text, from: opts.from });
36
- console.log(chalk.green(`Queued message ${msgId} for ${res.id}. `) +
37
- chalk.dim('The agent will see it at its next tool call.'));
139
+ const block = findOpenBlockForMailbox(res.id);
140
+ const session = findSessionForMailbox(res.id, sessions);
141
+ const route = resolveAnswerRoute({
142
+ mailboxId: res.id,
143
+ answer: text,
144
+ block,
145
+ session,
146
+ });
147
+ if (route.kind === 'refuse') {
148
+ die(route.reason);
149
+ }
150
+ // First-answer-wins for any path that closes an open block.
151
+ claimBlockAnswer(block, opts);
152
+ if (route.kind === 'mailbox') {
153
+ await deliverViaMailbox(res.id, text, block, opts);
154
+ return;
155
+ }
156
+ if (route.kind === 'tmux' || route.kind === 'iterm' || route.kind === 'pty') {
157
+ await deliverViaInject(route, res.id);
158
+ if (block) {
159
+ recordMessageReceipt(block.blockId, {
160
+ msgId: `inject-${Date.now()}`,
161
+ status: 'queued',
162
+ at: new Date().toISOString(),
163
+ from: opts.from,
164
+ });
165
+ }
166
+ return;
167
+ }
168
+ if (route.kind === 'resume') {
169
+ await deliverViaResume(route, res.id);
170
+ return;
171
+ }
172
+ die(`Unknown delivery route: ${route.kind}`);
38
173
  }
39
174
  catch (err) {
40
175
  die(err.message);
@@ -156,6 +156,18 @@ export function registerRoutinesCommands(program) {
156
156
 
157
157
  The background scheduler auto-starts the first time you add a routine.
158
158
  Manage it with 'agents routines start|stop|status'.
159
+
160
+ Version / credit failover (same semantics as 'agents run'):
161
+ - Omit 'version:' to let the configured run strategy (default: balanced)
162
+ pick a healthy install and skip accounts that are out of credits or
163
+ rate-limited. Pin with 'version: 2.1.x' when you want one install only.
164
+ - Foreground 'agents routines run' re-dispatches to the next healthy
165
+ same-agent account when a mid-run rate/usage limit is detected.
166
+ - Detached/daemon fires use the pre-flight pick only (next tick re-selects).
167
+ - Diagnostic lines log which account was picked, which were skipped, and
168
+ each failover hop: look for "[agents] routine <name>:" in the run log.
169
+ - Headless Claude auth: store CLAUDE_CODE_OAUTH_TOKEN in the 'claude'
170
+ secrets bundle so the daemon can inject it into routine spawns.
159
171
  `,
160
172
  });
161
173
  routinesCmd
@@ -20,5 +20,6 @@
20
20
  * writes and produces the backup.
21
21
  */
22
22
  import type { Command } from 'commander';
23
- /** Register `agents secrets migrate-acl` on the parent secrets Command. */
23
+ /** Register the keychain-migration commands (`migrate-acl`, `rekey`) on the
24
+ * parent secrets Command. */
24
25
  export declare function registerSecretsMigrateAclCommand(secrets: Command): void;
@@ -23,21 +23,24 @@ import chalk from 'chalk';
23
23
  import * as crypto from 'crypto';
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, listLegacyKeychainItems, listOrphanedKeychainItems, migrateOrphanedKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
26
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, listLegacyKeychainItems, listOrphanedKeychainItems, migrateOrphanedKeychainItems, rekeyServiceNames, rekeyStatus, setKeychainToken, withRawKeychainServiceNames, } from '../lib/secrets/index.js';
27
27
  import { getBackupsDir } from '../lib/state.js';
28
28
  import { encryptBlob, MIN_PASSPHRASE_LEN } from '../lib/secrets/sync.js';
29
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
30
30
  const ITEM_PREFIX = 'agents-cli.';
31
31
  function enumerateItems() {
32
32
  const seen = new Map();
33
- for (const item of listKeychainItems(ITEM_PREFIX)) {
34
- // hasKeychainToken with sync=false probes the non-synced keychain; the
35
- // helper's list returns both. We don't try to distinguish — re-write with
36
- // sync=false by default and only flip to sync=true if the value is only
37
- // readable via the synced-only probe.
38
- const localExists = hasKeychainToken(item);
39
- seen.set(item, { item, sync: !localExists });
40
- }
33
+ // Raw scope: operate on the literal enumerated names (see #316).
34
+ withRawKeychainServiceNames(() => {
35
+ for (const item of listKeychainItems(ITEM_PREFIX)) {
36
+ // hasKeychainToken with sync=false probes the non-synced keychain; the
37
+ // helper's list returns both. We don't try to distinguish — re-write with
38
+ // sync=false by default and only flip to sync=true if the value is only
39
+ // readable via the synced-only probe.
40
+ const localExists = hasKeychainToken(item);
41
+ seen.set(item, { item, sync: !localExists });
42
+ }
43
+ });
41
44
  return [...seen.values()];
42
45
  }
43
46
  function writeEncryptedBackup(records, passphrase) {
@@ -63,6 +66,12 @@ async function promptPassphrase() {
63
66
  return first;
64
67
  }
65
68
  function migrateOne(record) {
69
+ // Raw scope: `item` is a literal service name enumerated from the helper —
70
+ // possibly a pre-re-key cleartext leftover — and this rewrite must target
71
+ // exactly that item, never its hashed transform (see #316).
72
+ return withRawKeychainServiceNames(() => migrateOneRaw(record));
73
+ }
74
+ function migrateOneRaw(record) {
66
75
  const { item, value } = record;
67
76
  // Delete + re-add to force macOS to bind a fresh ACL on the new item.
68
77
  // SecItemUpdate preserves the existing ACL, so an in-place rewrite would
@@ -97,8 +106,10 @@ function migrateOne(record) {
97
106
  }
98
107
  return { item, status: 'ok' };
99
108
  }
100
- /** Register `agents secrets migrate-acl` on the parent secrets Command. */
109
+ /** Register the keychain-migration commands (`migrate-acl`, `rekey`) on the
110
+ * parent secrets Command. */
101
111
  export function registerSecretsMigrateAclCommand(secrets) {
112
+ registerSecretsRekeyCommand(secrets);
102
113
  secrets
103
114
  .command('migrate-acl')
104
115
  .description('Refresh legacy keychain ACLs and re-home items stranded in a stale access group. Dry-run by default.')
@@ -122,9 +133,14 @@ export function registerSecretsMigrateAclCommand(secrets) {
122
133
  // pre-#279 non-concrete group, invisible to the pinned queries.
123
134
  // Re-homed by the helper behind a single Touch ID.
124
135
  // Default: only legacy stragglers for (a); `--all` forces a full rewrite.
125
- const names = opts.all ? listKeychainItems(prefix) : listLegacyKeychainItems(prefix);
136
+ // Raw scope: migrate-acl operates on the literal names the helper
137
+ // enumerates (which may include pre-re-key cleartext leftovers) — the
138
+ // #316 name-hashing transform must not rewrite them.
139
+ const names = opts.all
140
+ ? withRawKeychainServiceNames(() => listKeychainItems(prefix))
141
+ : listLegacyKeychainItems(prefix);
126
142
  const items = names.map((item) => {
127
- const localExists = hasKeychainToken(item);
143
+ const localExists = withRawKeychainServiceNames(() => hasKeychainToken(item));
128
144
  return { item, sync: !localExists };
129
145
  });
130
146
  const orphans = listOrphanedKeychainItems(prefix);
@@ -160,7 +176,7 @@ export function registerSecretsMigrateAclCommand(secrets) {
160
176
  // read failure here counts the legacy items as failed but does NOT abort —
161
177
  // the orphan sweep (b) is independent and must still run.
162
178
  if (items.length > 0) {
163
- const fetched = getKeychainTokens(items.map((i) => i.item));
179
+ const fetched = withRawKeychainServiceNames(() => getKeychainTokens(items.map((i) => i.item)));
164
180
  const records = [];
165
181
  for (const { item, sync } of items) {
166
182
  const value = fetched.get(item);
@@ -257,3 +273,62 @@ export function registerSecretsMigrateAclCommand(secrets) {
257
273
  }
258
274
  });
259
275
  }
276
+ /**
277
+ * `agents secrets rekey` — the one-time #316 migration: replace enumerable
278
+ * cleartext keychain service names (`agents-cli.secrets.<bundle>.<KEY>`, …)
279
+ * with opaque HMAC-hashed names. Normally runs automatically on the first
280
+ * interactive keychain use after upgrade; this command exists for headless
281
+ * machines, retrying after a cancelled Touch ID, and prefix-restricted test
282
+ * runs. Idempotent — safe to re-run any time.
283
+ */
284
+ function registerSecretsRekeyCommand(secrets) {
285
+ secrets
286
+ .command('rekey')
287
+ .description('Replace enumerable keychain service names with opaque HMAC-hashed names (one-time; idempotent). macOS only.')
288
+ .option('--status', 'Report re-key state without changing anything')
289
+ .option('--prefix <p...>', 'Restrict to cleartext services starting with PREFIX (partial run: moves matching items but does NOT activate hashed naming)')
290
+ .action((opts) => {
291
+ try {
292
+ if (process.platform !== 'darwin') {
293
+ throw new Error('secrets rekey is macOS-only. Linux/Windows service names are not enumerable via the keychain helper.');
294
+ }
295
+ if (opts.status) {
296
+ const st = rekeyStatus();
297
+ console.log(st.migrated
298
+ ? chalk.green('Hashed service names: ACTIVE')
299
+ : chalk.yellow('Hashed service names: not yet active'));
300
+ console.log(` HMAC key present: ${st.hasKey ? 'yes' : 'no'}`);
301
+ if (!st.enumerationOk) {
302
+ console.log(chalk.red(' Keychain enumeration unavailable (locked keybag?) — counts below are unreliable.'));
303
+ }
304
+ console.log(` Cleartext-named items remaining: ${st.cleartext.length}`);
305
+ if (st.pendingDeletes > 0) {
306
+ console.log(chalk.yellow(` Pending deletes from an interrupted run: ${st.pendingDeletes} (finished automatically on next use)`));
307
+ }
308
+ return;
309
+ }
310
+ for (const p of opts.prefix ?? []) {
311
+ if (!p.startsWith(ITEM_PREFIX)) {
312
+ throw new Error(`--prefix must start with '${ITEM_PREFIX}' to avoid touching unrelated Keychain items (got '${p}').`);
313
+ }
314
+ }
315
+ const report = rekeyServiceNames({
316
+ prefixes: opts.prefix,
317
+ announce: true,
318
+ log: (line) => console.log(line),
319
+ });
320
+ if (report.failed.length > 0)
321
+ process.exit(1);
322
+ if (report.nothingToDo && report.activated) {
323
+ console.log(chalk.green('Nothing to re-key — hashed service names are active.'));
324
+ }
325
+ }
326
+ catch (err) {
327
+ const msg = err.message;
328
+ console.error(chalk.red(msg));
329
+ // Touch ID cancel exits 4 (matching the helper's contract); re-running
330
+ // resumes where it left off.
331
+ process.exit(/touch id cancelled/i.test(msg) ? 4 : 1);
332
+ }
333
+ });
334
+ }
@@ -208,10 +208,13 @@ async function importFromICloud(bundleName, opts) {
208
208
  parts.push(`skipped ${result.skipped} (already set, pass --force)`);
209
209
  if (result.missing.length)
210
210
  parts.push(`unreadable (left in iCloud): ${result.missing.join(', ')}`);
211
+ if (result.unimportable.length)
212
+ parts.push(`reserved, not importable (left in iCloud): ${result.unimportable.join(', ')}`);
211
213
  if (opts.purge)
212
214
  parts.push(`purged ${result.purged} iCloud item(s)`);
213
215
  const line = `${candidate.name}: ${parts.join(', ')}`;
214
- console.log(result.missing.length ? chalk.yellow(line) : chalk.green(line));
216
+ const warn = result.missing.length > 0 || result.unimportable.length > 0;
217
+ console.log(warn ? chalk.yellow(line) : chalk.green(line));
215
218
  }
216
219
  }
217
220
  /**
@@ -25,7 +25,7 @@ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
25
25
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
26
26
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
27
27
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
28
- import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
28
+ import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
29
29
  import { filterTeamSessions } from '../lib/session/team-filter.js';
30
30
  import { parseSession } from '../lib/session/parse.js';
31
31
  import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
@@ -796,6 +796,14 @@ async function sessionsAction(query, options) {
796
796
  if (options.device && options.device.length > 0) {
797
797
  options.host = [...(options.host ?? []), ...options.device];
798
798
  }
799
+ // --roots: emit the local session-scan directories, per agent, as JSON. A pure
800
+ // machine-readable query (no listing/render) — external watchers (the Factory
801
+ // extension's fs.watch) read it to track the same dirs the CLI scans, instead
802
+ // of hardcoding `~/.claude|.codex|.gemini`. Always local; ignores other flags.
803
+ if (options.roots) {
804
+ process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
805
+ return;
806
+ }
799
807
  // --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
800
808
  // clean merged SessionMeta[] array (same shape as the local --json path), for
801
809
  // scripts/extensions that JSON.parse a remote's history. Without --json it
@@ -2047,6 +2055,7 @@ export function registerSessionsCommands(program) {
2047
2055
  .option('--artifacts', 'List all files written or edited during a session')
2048
2056
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
2049
2057
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
2058
+ .option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
2050
2059
  .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
2051
2060
  .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
2052
2061
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { die } from '../lib/format.js';
3
+ import { getGitRoot } from '../lib/git.js';
3
4
  import { execFile } from 'child_process';
4
5
  import { promisify } from 'util';
5
6
  import * as fs from 'fs/promises';
@@ -14,10 +15,11 @@ function isValidTerminalId(id) {
14
15
  // hostile or buggy caller can't inject path traversal or shell metachars.
15
16
  return /^[A-Za-z0-9._-]+$/.test(id) && id.length > 0 && id.length <= 128;
16
17
  }
18
+ // Thin command-layer wrapper over the shared lib/git.ts `getGitRoot`: turns its
19
+ // throw into a clean `die()` message. The git plumbing lives in one place now.
17
20
  async function gitRoot(cwd) {
18
21
  try {
19
- const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd });
20
- return stdout.trim();
22
+ return await getGitRoot(cwd);
21
23
  }
22
24
  catch {
23
25
  die(`Not inside a git repo: ${cwd}`);
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -73,6 +73,7 @@ program
73
73
  .name('agents')
74
74
  .description('Environment manager for AI agents')
75
75
  .version(VERSION)
76
+ .option('--verbose', 'Show startup self-heal details on stderr')
76
77
  .helpOption('-h, --help', 'Show help')
77
78
  .addHelpCommand(false);
78
79
  // ─── Audit backbone ────────────────────────────────────────────────────────────
@@ -207,6 +208,7 @@ Automation tips:
207
208
  Options:
208
209
  -V, --version Show version number
209
210
  -h, --help Show help
211
+ --verbose Show startup self-heal details on stderr
210
212
 
211
213
  System config lives in ~/.agents/.system/. Run 'agents <command> --help' for details.
212
214
  `;
@@ -464,8 +466,8 @@ async function checkForUpdates() {
464
466
  }
465
467
  }
466
468
  }
467
- async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequested) {
468
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
469
+ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequested, verboseStartup) {
470
+ if (!verboseStartup && (!process.stdin.isTTY || !process.stdout.isTTY)) {
469
471
  return;
470
472
  }
471
473
  // Pure documentation paths must never trigger interactive repair — mirrors
@@ -487,8 +489,12 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
487
489
  // (a real native binary shadowing the shim) or is worth saying once (a PATH entry
488
490
  // just added). Suppression is persistent and keyed to the condition — a new
489
491
  // terminal no longer re-nags (the old per-PPID sentinel did, every shell).
490
- const { healShimsInteractive } = await import('./lib/shim-heal.js');
491
- const noticeLines = await healShimsInteractive();
492
+ const { runInteractiveShimHeal } = await import('./lib/shim-heal.js');
493
+ const { summarizeSelfHeal } = await import('./lib/self-heal/registry.js');
494
+ const { noticeLines, report } = await runInteractiveShimHeal();
495
+ if (verboseStartup) {
496
+ process.stderr.write(`[agents] startup self-heal: ${summarizeSelfHeal(report)}\n`);
497
+ }
492
498
  if (noticeLines) {
493
499
  for (const line of noticeLines)
494
500
  console.log(chalk.gray(line));
@@ -499,17 +505,8 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
499
505
  // close over entry-point-local state (program re-parsing, VERSION, the npm
500
506
  // upgrade helpers). The lazy registrar and the all-commands fallback below both
501
507
  // call them, so the behavior is identical to the old eager registration.
502
- /** Deprecated `memory` command hard error pointing users at `rules`. */
503
- function registerMemoryCommand(p) {
504
- p.command('memory', { hidden: true })
505
- .allowUnknownOption()
506
- .allowExcessArguments()
507
- .action(() => {
508
- console.error(chalk.red('"agents memory" has been renamed to "agents rules".'));
509
- console.error(chalk.gray('Run "agents rules --help" for usage.\n'));
510
- process.exit(1);
511
- });
512
- }
508
+ // memory is a first-class resource command (see commands/memory.ts via
509
+ // COMMAND_LOADERS). The old memory→rules tombstone was removed in RUSH-1330.
513
510
  /** Deprecated `perms` alias — re-parses as `permissions`. */
514
511
  function registerPermsAliasCommand(p) {
515
512
  p.command('perms', { hidden: true })
@@ -616,9 +613,6 @@ async function reg(loader) {
616
613
  */
617
614
  async function registerEagerForRequest(name) {
618
615
  switch (name) {
619
- case 'memory':
620
- registerMemoryCommand(program);
621
- return true;
622
616
  case 'perms':
623
617
  // The action re-parses as `permissions`, so that target must exist too.
624
618
  registerPermsAliasCommand(program);
@@ -659,7 +653,7 @@ async function registerAllEagerCommands() {
659
653
  await reg(loadHooks);
660
654
  await reg(loadSkills);
661
655
  await reg(loadRules);
662
- registerMemoryCommand(program);
656
+ await reg(loadMemory);
663
657
  await reg(loadPermissions);
664
658
  registerPermsAliasCommand(program);
665
659
  await reg(loadMcp);
@@ -768,6 +762,7 @@ program.on('command:*', (operands) => {
768
762
  // and whether the update check + background sync run at all.
769
763
  const passedArgs = process.argv.slice(2);
770
764
  const requestedCommand = passedArgs.find((arg) => !arg.startsWith('-'));
765
+ const verboseStartup = passedArgs.includes('--verbose');
771
766
  // Help and version output are pure documentation — they must never gate on
772
767
  // setup, otherwise `agents <cmd> --help` becomes useless on a fresh box.
773
768
  const helpOrVersionRequested = passedArgs.some((arg) => arg === '--help' || arg === '-h' || arg === '--version' || arg === '-V');
@@ -922,7 +917,7 @@ if (passedArgs.length === 0) {
922
917
  process.exit(0);
923
918
  }
924
919
  try {
925
- await maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequested);
920
+ await maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequested, verboseStartup);
926
921
  await program.parseAsync();
927
922
  }
928
923
  catch (err) {