@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.
- package/CHANGELOG.md +88 -0
- package/README.md +39 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.d.ts +13 -0
- package/dist/commands/accounts.js +32 -0
- package/dist/commands/daemon.d.ts +18 -0
- package/dist/commands/daemon.js +581 -0
- package/dist/commands/exec.js +66 -20
- package/dist/commands/routines.js +29 -11
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +30 -15
- package/dist/commands/sessions-browser.js +6 -6
- package/dist/commands/sessions-favorite.d.ts +7 -7
- package/dist/commands/sessions-favorite.js +30 -30
- package/dist/commands/sessions-picker.d.ts +33 -1
- package/dist/commands/sessions-picker.js +102 -27
- package/dist/commands/sessions.d.ts +12 -1
- package/dist/commands/sessions.js +259 -20
- package/dist/commands/view.d.ts +11 -0
- package/dist/commands/view.js +56 -29
- package/dist/index.js +37 -2
- package/dist/lib/account-labels.d.ts +24 -0
- package/dist/lib/account-labels.js +72 -0
- package/dist/lib/agents.d.ts +32 -1
- package/dist/lib/agents.js +96 -31
- package/dist/lib/daemon-health.d.ts +24 -0
- package/dist/lib/daemon-health.js +84 -0
- package/dist/lib/daemon-ticks.d.ts +81 -0
- package/dist/lib/daemon-ticks.js +190 -0
- package/dist/lib/daemon.d.ts +68 -18
- package/dist/lib/daemon.js +303 -338
- package/dist/lib/device-config.d.ts +10 -0
- package/dist/lib/device-config.js +27 -0
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +49 -2
- package/dist/lib/hosts/dispatch.d.ts +4 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +1 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/import.js +7 -6
- package/dist/lib/memory-cache.d.ts +19 -0
- package/dist/lib/memory-cache.js +31 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +16 -0
- package/dist/lib/menubar/snapshot.js +22 -1
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +7 -2
- package/dist/lib/routine-activation.d.ts +2 -0
- package/dist/lib/routine-activation.js +16 -0
- package/dist/lib/runner.d.ts +18 -0
- package/dist/lib/runner.js +52 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -0
- package/dist/lib/secrets/agent.js +32 -2
- package/dist/lib/secrets/scope.d.ts +3 -3
- package/dist/lib/secrets/scope.js +3 -3
- package/dist/lib/session/db.d.ts +15 -0
- package/dist/lib/session/db.js +90 -15
- package/dist/lib/session/discover.js +91 -39
- package/dist/lib/session/favorites.d.ts +2 -2
- package/dist/lib/session/favorites.js +2 -2
- package/dist/lib/session/parse.d.ts +63 -0
- package/dist/lib/session/parse.js +165 -20
- package/dist/lib/session/session-cache.d.ts +9 -6
- package/dist/lib/session/session-cache.js +23 -6
- package/dist/lib/shims.js +12 -0
- package/dist/lib/startup/command-registry.d.ts +15 -1
- package/dist/lib/startup/command-registry.js +49 -0
- package/dist/lib/usage-refresh.js +3 -2
- package/dist/lib/usage.d.ts +12 -10
- package/dist/lib/usage.js +81 -154
- 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
|
|
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
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
8
|
-
/** Daemon warm interval —
|
|
9
|
-
export declare const SESSION_CACHE_WARM_INTERVAL_MS
|
|
10
|
-
/** Kick off the first warm
|
|
11
|
-
export declare const SESSION_CACHE_WARM_KICKOFF_MS =
|
|
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
|
|
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 =
|
|
38
|
-
/** Daemon warm interval —
|
|
39
|
-
export const SESSION_CACHE_WARM_INTERVAL_MS =
|
|
40
|
-
/** Kick off the first warm
|
|
41
|
-
export const SESSION_CACHE_WARM_KICKOFF_MS =
|
|
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
|
|
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
|
-
//
|
|
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,
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -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`
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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`
|
|
478
|
-
*
|
|
479
|
-
*
|
|
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;
|