@phnx-labs/agents-cli 1.22.28 → 1.22.30

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 +88 -0
  2. package/README.md +39 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/snapshot.d.ts +16 -0
  47. package/dist/lib/menubar/snapshot.js +22 -1
  48. package/dist/lib/migrate.d.ts +1 -1
  49. package/dist/lib/migrate.js +7 -2
  50. package/dist/lib/routine-activation.d.ts +2 -0
  51. package/dist/lib/routine-activation.js +16 -0
  52. package/dist/lib/runner.d.ts +18 -0
  53. package/dist/lib/runner.js +52 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  55. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  56. package/dist/lib/secrets/agent.d.ts +19 -0
  57. package/dist/lib/secrets/agent.js +32 -2
  58. package/dist/lib/secrets/scope.d.ts +3 -3
  59. package/dist/lib/secrets/scope.js +3 -3
  60. package/dist/lib/session/db.d.ts +15 -0
  61. package/dist/lib/session/db.js +90 -15
  62. package/dist/lib/session/discover.js +91 -39
  63. package/dist/lib/session/favorites.d.ts +2 -2
  64. package/dist/lib/session/favorites.js +2 -2
  65. package/dist/lib/session/parse.d.ts +63 -0
  66. package/dist/lib/session/parse.js +165 -20
  67. package/dist/lib/session/session-cache.d.ts +9 -6
  68. package/dist/lib/session/session-cache.js +23 -6
  69. package/dist/lib/shims.js +12 -0
  70. package/dist/lib/startup/command-registry.d.ts +15 -1
  71. package/dist/lib/startup/command-registry.js +49 -0
  72. package/dist/lib/usage-refresh.js +3 -2
  73. package/dist/lib/usage.d.ts +12 -10
  74. package/dist/lib/usage.js +81 -154
  75. package/package.json +4 -1
@@ -1125,6 +1125,40 @@ export function parseAntigravity(dbPath) {
1125
1125
  return events;
1126
1126
  }
1127
1127
  // ---------------------------------------------------------------------------
1128
+ // Composite session file paths
1129
+ // ---------------------------------------------------------------------------
1130
+ /**
1131
+ * Separator between a container file and the id it holds inside a *composite*
1132
+ * session `file_path`. Some harnesses keep every session in ONE file (OpenCode's
1133
+ * single `opencode.db`), so the index stores `<container>#<session-id>` rather
1134
+ * than one path per session — e.g.
1135
+ * `/home/u/.local/share/opencode/opencode.db#ses_02410a2c…`.
1136
+ */
1137
+ export const SESSION_FILE_PATH_SEP = '#';
1138
+ /**
1139
+ * Split a stored session `file_path` into its on-disk container and the optional
1140
+ * in-container fragment. For a plain per-session file the container is the path
1141
+ * itself and `fragment` is undefined; for a composite path it is the part before
1142
+ * the first `#` and the id after it.
1143
+ */
1144
+ export function splitSessionFilePath(filePath) {
1145
+ const hash = filePath.indexOf(SESSION_FILE_PATH_SEP);
1146
+ if (hash < 0)
1147
+ return { container: filePath, fragment: undefined };
1148
+ return { container: filePath.slice(0, hash), fragment: filePath.slice(hash + 1) || undefined };
1149
+ }
1150
+ /**
1151
+ * The filesystem path whose existence/stat decides whether a session row is
1152
+ * stale. For a composite `file_path` this is the CONTAINER file — the row is a
1153
+ * record inside it, never a filesystem entry of its own — so a composite session
1154
+ * is stale only when its container is gone. For a plain path it is the path
1155
+ * itself. Keying off the composite FORM (a `#` fragment), not any harness name,
1156
+ * means any future single-file-DB harness inherits the correct behavior.
1157
+ */
1158
+ export function sessionFilePathContainer(filePath) {
1159
+ return splitSessionFilePath(filePath).container;
1160
+ }
1161
+ // ---------------------------------------------------------------------------
1128
1162
  // OpenCode parser
1129
1163
  // ---------------------------------------------------------------------------
1130
1164
  /**
@@ -1279,36 +1313,124 @@ export function parseGrok(filePath) {
1279
1313
  }
1280
1314
  return events;
1281
1315
  }
1316
+ /**
1317
+ * Byte/char caps for the OpenCode tool-part projection below. Numeric
1318
+ * constants, interpolated into the SQL — never user input.
1319
+ */
1320
+ const OPENCODE_OUTPUT_MAX_CHARS = 2000;
1321
+ const OPENCODE_INPUT_MAX_BYTES = 4000;
1322
+ /**
1323
+ * The transcript query {@link parseOpenCode} runs, exported so a test can assert
1324
+ * the projection's real cost against a database instead of re-typing the SQL.
1325
+ *
1326
+ * A tool part is PROJECTED to exactly the fields the `case 'tool'` branch below
1327
+ * reads — `tool`, `callID`, `state.status`, `state.input`, `state.output` —
1328
+ * rather than carried whole. Two things this has to get right at once:
1329
+ *
1330
+ * - Keep `state.input`. It carries `filePath` / `command`, and losing it is
1331
+ * what left `recentDirectoriesTouched` empty (RUSH-2358).
1332
+ * - Stay bounded. A tool part is not bounded by its output: on a real
1333
+ * `opencode.db` the largest part is 1,346,068 bytes of which
1334
+ * `state.attachments` (a base64 data URL from `read`) is 1,345,674, while
1335
+ * `state.output` is 23. Truncating only `state.output` therefore bounded
1336
+ * nothing — it took one session's loaded tool payload from 299,365 to
1337
+ * 1,911,209 bytes. The projection drops `attachments` (and every other
1338
+ * unread key) outright, caps `state.output`, and collapses an oversized
1339
+ * `state.input` to just its addressing fields. Those are the keys the
1340
+ * enrichment reads — `filePath`/`path` for an edit and `cwd`/`workdir`/
1341
+ * `working_directory` for a shell (`extractRecentDirectoriesTouched` in
1342
+ * state.ts), plus `command`/`description`, themselves capped since a command
1343
+ * can be arbitrarily long. Above the cap every other input key (an `edit`'s
1344
+ * `oldString`/`newString`) is gone, deliberately — nothing downstream reads
1345
+ * them, and they are the weight.
1346
+ *
1347
+ * `json_valid` guards every `json_extract`: SQLite raises "malformed JSON" on a
1348
+ * non-JSON value, which aborts the WHOLE query, so one bad `part` row would
1349
+ * otherwise cost the entire transcript. Note the single-argument form is
1350
+ * RFC-8259-strict — it rejects a JSONB blob that `json_extract` would accept.
1351
+ * That is correct for today's schema (`part.data` / `message.data` are TEXT on
1352
+ * a real database); if OpenCode ever migrates them to JSONB, these guards must
1353
+ * move to `json_valid(data, 6)` or they will drop every row.
1354
+ *
1355
+ * The session id is bound as a parameter; the two caps are numeric literals.
1356
+ */
1357
+ export const OPENCODE_TRANSCRIPT_QUERY = `
1358
+ SELECT
1359
+ CASE WHEN json_valid(m.data) THEN json_extract(m.data, '$.role') END AS role,
1360
+ CASE WHEN json_valid(p.data) THEN json_extract(p.data, '$.type') END AS part_type,
1361
+ CASE
1362
+ WHEN json_valid(p.data) AND json_extract(p.data, '$.type') = 'tool'
1363
+ THEN json_object(
1364
+ 'type', 'tool',
1365
+ 'tool', json_extract(p.data, '$.tool'),
1366
+ 'callID', json_extract(p.data, '$.callID'),
1367
+ 'state', json_object(
1368
+ 'status', json_extract(p.data, '$.state.status'),
1369
+ 'input', CASE
1370
+ WHEN LENGTH(CAST(COALESCE(json_extract(p.data, '$.state.input'), '') AS BLOB)) > ${OPENCODE_INPUT_MAX_BYTES}
1371
+ THEN json_object(
1372
+ 'filePath', json_extract(p.data, '$.state.input.filePath'),
1373
+ 'path', json_extract(p.data, '$.state.input.path'),
1374
+ 'command', substr(COALESCE(json_extract(p.data, '$.state.input.command'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS}),
1375
+ 'description', substr(COALESCE(json_extract(p.data, '$.state.input.description'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS}),
1376
+ 'cwd', json_extract(p.data, '$.state.input.cwd'),
1377
+ 'workdir', json_extract(p.data, '$.state.input.workdir'),
1378
+ 'working_directory', json_extract(p.data, '$.state.input.working_directory')
1379
+ )
1380
+ ELSE json_extract(p.data, '$.state.input')
1381
+ END,
1382
+ 'output', substr(COALESCE(json_extract(p.data, '$.state.output'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS})
1383
+ )
1384
+ )
1385
+ ELSE p.data
1386
+ END AS part_data,
1387
+ m.time_created AS time_created
1388
+ FROM message m
1389
+ JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
1390
+ WHERE m.session_id = ?
1391
+ ORDER BY m.time_created ASC, p.time_created ASC;
1392
+ `.replace(/\n/g, ' ');
1282
1393
  export function parseOpenCode(filePath) {
1283
- const [dbPath, sessionId] = filePath.split('#');
1394
+ const { container: dbPath, fragment: sessionId } = splitSessionFilePath(filePath);
1284
1395
  if (!dbPath || !sessionId)
1285
1396
  return [];
1286
1397
  const events = [];
1287
1398
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1288
1399
  // works on every OS — the CLI is absent on Windows.
1289
1400
  let rows;
1401
+ // OpenCode stores the session's checklist in its own `todo` table (the current
1402
+ // snapshot, not a history). Emitted below as one `todo_write` tool_use event so
1403
+ // the shared enrichment (`extractTodoProgressFromEvents`) computes `todos`
1404
+ // uniformly with every other harness (RUSH-2358).
1405
+ let todoRows = [];
1290
1406
  let db;
1291
1407
  try {
1292
- // Query messages with their parts, ordered chronologically. Tool parts are
1293
- // truncated to keep large tool outputs from bloating memory; the session id
1294
- // is bound as a parameter rather than interpolated.
1295
- const query = `
1296
- SELECT
1297
- json_extract(m.data, '$.role') AS role,
1298
- json_extract(p.data, '$.type') AS part_type,
1299
- CASE
1300
- WHEN json_extract(p.data, '$.type') = 'tool'
1301
- THEN substr(p.data, 1, 2000)
1302
- ELSE p.data
1303
- END AS part_data,
1304
- m.time_created AS time_created
1305
- FROM message m
1306
- JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
1307
- WHERE m.session_id = ?
1308
- ORDER BY m.time_created ASC, p.time_created ASC;
1309
- `.replace(/\n/g, ' ');
1408
+ // Messages with their parts, ordered chronologically. The query — and why
1409
+ // the tool part is projected rather than carried whole is documented on
1410
+ // OPENCODE_TRANSCRIPT_QUERY above.
1310
1411
  db = new Database(dbPath);
1311
- rows = db.prepare(query).all(sessionId);
1412
+ rows = db.prepare(OPENCODE_TRANSCRIPT_QUERY).all(sessionId);
1413
+ // The `todo` table is a newer OpenCode addition. Probe for it the same way
1414
+ // the scanner probes newer `session` columns, rather than wrapping the read
1415
+ // in a blanket catch — a catch there reported a locked, corrupt, or
1416
+ // permission-denied database as "this session has no todos".
1417
+ //
1418
+ // Row COUNT, not an empty-`get()` sentinel: the two production runtimes
1419
+ // disagree on what `get()` returns for no row — node:sqlite gives
1420
+ // `undefined`, bun:sqlite gives `null` (both ship; see sqlite.ts). A check
1421
+ // written against either sentinel is always-true on the other runtime,
1422
+ // which would run the `todo` SELECT on a schema that has no such table,
1423
+ // throw, and hand the whole transcript to the outer catch — an empty
1424
+ // session, silently, and only in the shipped Bun binary. `.all().length`
1425
+ // cannot express that disagreement.
1426
+ const hasTodoTable = db
1427
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'todo';`)
1428
+ .all().length > 0;
1429
+ if (hasTodoTable) {
1430
+ todoRows = db
1431
+ .prepare('SELECT content, status, time_updated FROM todo WHERE session_id = ? ORDER BY position ASC;')
1432
+ .all(sessionId);
1433
+ }
1312
1434
  }
1313
1435
  catch {
1314
1436
  /* DB not accessible, sqlite module unavailable, or query failed */
@@ -1400,6 +1522,29 @@ export function parseOpenCode(filePath) {
1400
1522
  catch {
1401
1523
  /* malformed row payload — return what we parsed so far */
1402
1524
  }
1525
+ // Emit the OpenCode `todo` table as one `todo_write` snapshot so the shared
1526
+ // enrichment layer derives `todos` the same way it does for every harness that
1527
+ // records a checklist tool. `todo_write` is in SNAPSHOT_TODO_TOOLS, and the
1528
+ // enrichment reads `args.todos`, so the shape matches without special-casing.
1529
+ const todos = todoRows
1530
+ .map(t => ({
1531
+ content: typeof t.content === 'string' ? t.content : '',
1532
+ status: typeof t.status === 'string' ? t.status : 'pending',
1533
+ }))
1534
+ .filter(t => t.content.trim());
1535
+ if (todos.length) {
1536
+ const lastTodoMs = todoRows.reduce((max, t) => {
1537
+ const ms = typeof t.time_updated === 'number' ? t.time_updated : parseInt(String(t.time_updated), 10);
1538
+ return Number.isFinite(ms) && ms > max ? ms : max;
1539
+ }, 0);
1540
+ events.push({
1541
+ type: 'tool_use',
1542
+ agent: 'opencode',
1543
+ timestamp: lastTodoMs > 0 ? new Date(lastTodoMs).toISOString() : new Date().toISOString(),
1544
+ tool: 'todo_write',
1545
+ args: { todos },
1546
+ });
1547
+ }
1403
1548
  return events;
1404
1549
  }
1405
1550
  // ---------------------------------------------------------------------------
@@ -2,13 +2,14 @@ import type { ActiveSession } from './active.js';
2
2
  /**
3
3
  * How long a snapshot may be served before a reader re-gathers.
4
4
  * Short on purpose: live status (running/idle/waiting) must not go stale.
5
- * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
5
+ * The daemon warms more frequently than this ceiling so normal readers hit a
6
+ * fresh snapshot while expiry still forces a live gather after a missed tick.
6
7
  */
7
- export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS: number;
8
- /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
9
- export declare const SESSION_CACHE_WARM_INTERVAL_MS: number;
10
- /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
11
- export declare const SESSION_CACHE_WARM_KICKOFF_MS = 30000;
8
+ export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15000;
9
+ /** Daemon warm interval — below the freshness ceiling to avoid an expiry gap. */
10
+ export declare const SESSION_CACHE_WARM_INTERVAL_MS = 10000;
11
+ /** Kick off the first warm shortly after daemon start, staggered off bootstrap. */
12
+ export declare const SESSION_CACHE_WARM_KICKOFF_MS = 5000;
12
13
  /** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
13
14
  export type ActiveCacheScope = 'local' | 'fleet';
14
15
  export interface ActiveSessionsSnapshot {
@@ -56,6 +57,8 @@ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "
56
57
  export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
57
58
  /** Test seam: redirect the snapshot file. Returns the previous override. */
58
59
  export declare function setActiveSessionsSnapshotPathForTest(p: string | null): string | null;
60
+ /** Test seam: isolate process-local entries between fixtures. */
61
+ export declare function clearActiveSnapshotMemoryForTest(): void;
59
62
  /** Test seam: redirect the immutable-memo file. Returns the previous override. */
60
63
  export declare function setImmutableMemoPathForTest(p: string | null): string | null;
61
64
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import * as fs from 'fs';
26
26
  import * as path from 'path';
27
+ import { createMemoryCache } from '../memory-cache.js';
27
28
  import { getCacheDir } from '../state.js';
28
29
  /** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
29
30
  const SNAPSHOT_FILE = '.active-sessions.json';
@@ -32,13 +33,19 @@ const IMMUTABLE_FILE = '.active-session-immutable.json';
32
33
  /**
33
34
  * How long a snapshot may be served before a reader re-gathers.
34
35
  * Short on purpose: live status (running/idle/waiting) must not go stale.
35
- * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
36
+ * The daemon warms more frequently than this ceiling so normal readers hit a
37
+ * fresh snapshot while expiry still forces a live gather after a missed tick.
36
38
  */
37
- export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 4 * 60_000;
38
- /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
39
- export const SESSION_CACHE_WARM_INTERVAL_MS = 3 * 60_000;
40
- /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
41
- export const SESSION_CACHE_WARM_KICKOFF_MS = 30_000;
39
+ export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15_000;
40
+ /** Daemon warm interval — below the freshness ceiling to avoid an expiry gap. */
41
+ export const SESSION_CACHE_WARM_INTERVAL_MS = 10_000;
42
+ /** Kick off the first warm shortly after daemon start, staggered off bootstrap. */
43
+ export const SESSION_CACHE_WARM_KICKOFF_MS = 5_000;
44
+ /** Process-local L1. The atomic snapshot remains the cross-process source. */
45
+ const activeSnapshotMemory = createMemoryCache({
46
+ max: 2,
47
+ ttlMs: DEFAULT_ACTIVE_CACHE_MAX_AGE_MS,
48
+ });
42
49
  /** Keys stored in the immutable memo (transcript-stable). */
43
50
  export const IMMUTABLE_FIELD_KEYS = [
44
51
  'topic',
@@ -92,8 +99,13 @@ let immutablePathOverride = null;
92
99
  export function setActiveSessionsSnapshotPathForTest(p) {
93
100
  const prev = snapshotPathOverride;
94
101
  snapshotPathOverride = p;
102
+ activeSnapshotMemory.clear();
95
103
  return prev;
96
104
  }
105
+ /** Test seam: isolate process-local entries between fixtures. */
106
+ export function clearActiveSnapshotMemoryForTest() {
107
+ activeSnapshotMemory.clear();
108
+ }
97
109
  /** Test seam: redirect the immutable-memo file. Returns the previous override. */
98
110
  export function setImmutableMemoPathForTest(p) {
99
111
  const prev = immutablePathOverride;
@@ -109,6 +121,9 @@ function immutablePath() {
109
121
  // ── snapshot read / write ──────────────────────────────────────────────────
110
122
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
111
123
  export function readActiveSessionsCache(scope) {
124
+ const memory = activeSnapshotMemory.get(scope);
125
+ if (memory)
126
+ return memory;
112
127
  try {
113
128
  const parsed = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
114
129
  if (!parsed || parsed.version !== 1 || !parsed.entries)
@@ -116,6 +131,7 @@ export function readActiveSessionsCache(scope) {
116
131
  const entry = parsed.entries[scope];
117
132
  if (!entry || !Array.isArray(entry.sessions) || typeof entry.capturedAt !== 'number')
118
133
  return null;
134
+ activeSnapshotMemory.set(scope, entry);
119
135
  return entry;
120
136
  }
121
137
  catch {
@@ -153,6 +169,7 @@ export function writeActiveSessionsCache(scope, sessions, opts = {}) {
153
169
  const tmp = `${snapshotPath()}.tmp`;
154
170
  fs.writeFileSync(tmp, JSON.stringify(body));
155
171
  fs.renameSync(tmp, snapshotPath());
172
+ activeSnapshotMemory.set(scope, snap);
156
173
  }
157
174
  catch {
158
175
  // best-effort
package/dist/lib/shims.js CHANGED
@@ -600,6 +600,18 @@ else
600
600
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
601
601
  fi
602
602
 
603
+ # A managed binary must never resolve back into this dispatcher. This can
604
+ # happen when an install-script launcher was imported before adoption and was
605
+ # later repointed at the agents shim. Use the durable native target recorded by
606
+ # adoption instead of recursively exec-ing this script.
607
+ if [ -x "$BINARY" ]; then
608
+ RESOLVED_BINARY=$(realpath "$BINARY" 2>/dev/null || readlink -f "$BINARY" 2>/dev/null || echo "")
609
+ RESOLVED_SHIM=$(realpath "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null || readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null || echo "")
610
+ if [ -n "$RESOLVED_BINARY" ] && [ "$RESOLVED_BINARY" = "$RESOLVED_SHIM" ]; then
611
+ BINARY=$(adopted_original_bin || echo "")
612
+ fi
613
+ fi
614
+
603
615
  # Auto-install if not present
604
616
  if [ ! -x "$BINARY" ]; then
605
617
  if [ "$VERSION_SOURCE" = "project" ]; then
@@ -18,7 +18,7 @@
18
18
  * versions (which creates `prune <specs...>`) and prune.js (which attaches the
19
19
  * `cleanup` subcommand to it), in that order — see commands/prune.ts.
20
20
  */
21
- import type { Command } from 'commander';
21
+ import { Command } from 'commander';
22
22
  /** A function that registers one or more commands onto the root program. */
23
23
  export type Registrar = (program: Command) => void;
24
24
  /** A thunk that dynamically imports a command module and returns its registrar. */
@@ -110,6 +110,8 @@ export declare const loadAudit: ModuleLoader;
110
110
  export declare const loadWebhook: ModuleLoader;
111
111
  export declare const loadFunnel: ModuleLoader;
112
112
  export declare const loadHumans: ModuleLoader;
113
+ export declare const loadAccounts: ModuleLoader;
114
+ export declare const loadDaemon: ModuleLoader;
113
115
  /**
114
116
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
115
117
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -148,3 +150,15 @@ export declare const COMMAND_LOADERS: Record<string, ModuleLoader[]>;
148
150
  export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
149
151
  /** Whether `name` is a top-level command this CLI registers. See {@link KNOWN_TOP_LEVEL_COMMANDS}. */
150
152
  export declare function isKnownTopLevelCommand(name: string): boolean;
153
+ /**
154
+ * Register every module in {@link COMMAND_LOADERS} onto one fresh program and
155
+ * return it — the full public command tree, deduped by loader identity so a
156
+ * loader mapped to several names (e.g. `add`/`use`/`list` -> versions) runs once.
157
+ *
158
+ * Off the hot path only: the command-index generator (`scripts/gen-command-index.ts`)
159
+ * and the tests build the tree from this. Startup never calls it — src/index.ts
160
+ * registers just the one requested command via `registerEagerForRequest`. The
161
+ * inline aliases/tombstones ({@link INLINE_COMMAND_NAMES}) are NOT included: they
162
+ * are closures over entry-point state that src/index.ts registers directly.
163
+ */
164
+ export declare function buildFullCommandTree(): Promise<Command>;
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Lazy command registry.
3
+ *
4
+ * The CLI entry point (src/index.ts) used to statically import every command
5
+ * module and call its `registerXCommand(program)` on every invocation. That
6
+ * loaded the entire command tree (~50 modules) before the first line of output,
7
+ * dominating cold-start latency.
8
+ *
9
+ * This module maps each user-typed top-level command name to a thunk that
10
+ * dynamically imports ONLY the module(s) that command needs. Fast commands
11
+ * (`--version`, `view`, ...) now pay for just the one module they use; the full
12
+ * tree is loaded only on the rare slow paths (unknown-command spellcheck, bare
13
+ * help) via `registerAllEagerCommands` in src/index.ts.
14
+ *
15
+ * Parity is non-negotiable: the name -> loader map below mirrors exactly which
16
+ * module registers which top-level command on `main`. Multi-command modules
17
+ * (versions, packages) map several names to the same loader; `prune` needs BOTH
18
+ * versions (which creates `prune <specs...>`) and prune.js (which attaches the
19
+ * `cleanup` subcommand to it), in that order — see commands/prune.ts.
20
+ */
21
+ import { Command } from 'commander';
1
22
  // One loader per command module. Each dynamically imports the module and hands
2
23
  // back its register function. Kept as named consts so src/index.ts can compose
3
24
  // them into the exact main-branch registration order for the slow path.
@@ -89,6 +110,8 @@ export const loadAudit = async () => (await import('../../commands/audit.js')).r
89
110
  export const loadWebhook = async () => (await import('../../commands/webhook.js')).registerWebhookCommand;
90
111
  export const loadFunnel = async () => (await import('../../commands/funnel.js')).registerFunnelCommand;
91
112
  export const loadHumans = async () => (await import('../../commands/humans.js')).registerHumansCommands;
113
+ export const loadAccounts = async () => (await import('../../commands/accounts.js')).registerAccountsCommand;
114
+ export const loadDaemon = async () => (await import('../../commands/daemon.js')).registerDaemonCommand;
92
115
  /**
93
116
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
94
117
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -123,6 +146,7 @@ export const LAZY_COMMAND_NAMES = new Set([
123
146
  * are handled directly in src/index.ts.
124
147
  */
125
148
  export const COMMAND_LOADERS = {
149
+ accounts: [loadAccounts],
126
150
  view: [loadView],
127
151
  inspect: [loadInspect],
128
152
  feedback: [loadFeedback],
@@ -241,6 +265,7 @@ export const COMMAND_LOADERS = {
241
265
  webhook: [loadWebhook],
242
266
  funnel: [loadFunnel],
243
267
  humans: [loadHumans],
268
+ daemon: [loadDaemon],
244
269
  };
245
270
  /**
246
271
  * Top-level names that {@link COMMAND_LOADERS} does not carry because they are
@@ -279,3 +304,27 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
279
304
  export function isKnownTopLevelCommand(name) {
280
305
  return KNOWN_TOP_LEVEL_COMMANDS.has(name);
281
306
  }
307
+ /**
308
+ * Register every module in {@link COMMAND_LOADERS} onto one fresh program and
309
+ * return it — the full public command tree, deduped by loader identity so a
310
+ * loader mapped to several names (e.g. `add`/`use`/`list` -> versions) runs once.
311
+ *
312
+ * Off the hot path only: the command-index generator (`scripts/gen-command-index.ts`)
313
+ * and the tests build the tree from this. Startup never calls it — src/index.ts
314
+ * registers just the one requested command via `registerEagerForRequest`. The
315
+ * inline aliases/tombstones ({@link INLINE_COMMAND_NAMES}) are NOT included: they
316
+ * are closures over entry-point state that src/index.ts registers directly.
317
+ */
318
+ export async function buildFullCommandTree() {
319
+ const program = new Command();
320
+ const done = new Set();
321
+ for (const loaders of Object.values(COMMAND_LOADERS)) {
322
+ for (const loader of loaders) {
323
+ if (done.has(loader))
324
+ continue;
325
+ done.add(loader);
326
+ (await loader())(program);
327
+ }
328
+ }
329
+ return program;
330
+ }
@@ -198,8 +198,9 @@ export async function buildLocalUsageAccounts() {
198
198
  usageKey,
199
199
  agentId,
200
200
  // fileOnly: never open the ACL-bound keychain item from the daemon —
201
- // that path is the Touch ID storm. Usage reads setup-token /
202
- // no-ACL cache / .credentials.json only (see loadClaudeOauth).
201
+ // that path is the Touch ID storm. Usage reads the file-based setup-token
202
+ // only, never the interactive login (see loadClaudeOauth); no setup-token
203
+ // reads as "usage pending".
203
204
  fetch: () => getUsageInfo(agentId, {
204
205
  home: fetchInput.home,
205
206
  cliVersion: fetchInput.cliVersion,
@@ -466,17 +466,19 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
466
466
  * (run remotely over SSH by `--host`) rendered no usage bars even though the
467
467
  * account + plan — read from the plaintext `.claude.json` — showed fine.
468
468
  *
469
- * `opts.accessTokenCache` opts INTO the no-ACL access-token cache (the Touch ID
470
- * storm fix, see the cache helpers above). It is OFF by default so every caller
471
- * keeps the full ACL-read credential the cached copy deliberately omits the
472
- * refresh token, so callers that refresh (`isClaudeAuthValid` ->
473
- * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
474
- * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
475
- * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
469
+ * `opts.accessTokenCache` marks a read-only, access-token-only consumer (the
470
+ * usage fetch and the auth-health probe). Such a caller authenticates ONLY with
471
+ * a file-based setup-token and, when none is provisioned, gets `null` — it never
472
+ * reads Claude Code's interactive login (transmitting that ACL-bound OAuth token
473
+ * to Anthropic's API is what gets it revoked; see the branch body and
474
+ * docs/design/credential-management.md). It is OFF by default so full-credential
475
+ * callers that refresh (`isClaudeAuthValid` -> `getClaudeAccessToken`) or export
476
+ * the full blob (`readClaudeCredentialsBlob` for Rush Cloud dispatch) still read
477
+ * the interactive login.
476
478
  *
477
- * `opts.fileOnly` (implies access-token-only consumers) skips the ACL keychain
478
- * read entirely — setup-token, no-ACL cache, and `.credentials.json` only. Used
479
- * by the daemon usage refresher so a background tick can never pop Touch ID.
479
+ * `opts.fileOnly` skips the ACL keychain read entirely — setup-token and
480
+ * `.credentials.json` only. Used by the daemon usage refresher so a background
481
+ * tick can never pop Touch ID.
480
482
  */
481
483
  export declare function loadClaudeOauth(home?: string, opts?: {
482
484
  accessTokenCache?: boolean;