@phnx-labs/agents-cli 1.22.115 → 1.22.116
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 +149 -14
- package/README.md +1 -1
- package/dist/commands/browser.js +11 -0
- package/dist/commands/exec.js +212 -141
- package/dist/commands/feed.js +65 -14
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +88 -7
- package/dist/commands/sessions.d.ts +21 -2
- package/dist/commands/sessions.js +157 -3
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-term.d.ts +24 -0
- package/dist/commands/setup-term.js +70 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +12 -4
- package/dist/commands/ssh.js +1 -69
- package/dist/lib/accounting/rotate.d.ts +63 -1
- package/dist/lib/accounting/rotate.js +56 -0
- package/dist/lib/accounts/add.js +2 -2
- package/dist/lib/accounts/slots.js +32 -2
- package/dist/lib/answer-router.d.ts +11 -2
- package/dist/lib/answer-router.js +26 -2
- package/dist/lib/auth-mint.d.ts +5 -4
- package/dist/lib/auth-mint.js +4 -3
- package/dist/lib/browser/drivers/arc.d.ts +1 -1
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +208 -36
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/config-keys.d.ts +1 -1
- package/dist/lib/config-keys.js +5 -0
- package/dist/lib/device-config.js +61 -0
- package/dist/lib/devices/doctor-findings.js +2 -6
- package/dist/lib/feed/answer.d.ts +153 -4
- package/dist/lib/feed/answer.js +716 -105
- package/dist/lib/feed/feed.d.ts +61 -1
- package/dist/lib/feed/feed.js +226 -14
- package/dist/lib/feed/hub-server.d.ts +58 -3
- package/dist/lib/feed/hub-server.js +306 -54
- package/dist/lib/feed/pr-status.d.ts +8 -0
- package/dist/lib/feed/pr-status.js +9 -1
- package/dist/lib/feed-outcome.d.ts +1 -1
- package/dist/lib/feed-outcome.js +9 -2
- package/dist/lib/feed-policy.js +9 -3
- package/dist/lib/fleet/auth-sync.d.ts +2 -55
- package/dist/lib/fleet/auth-sync.js +2 -89
- package/dist/lib/harness-auth-capabilities.js +7 -2
- package/dist/lib/hosts/dispatch.d.ts +20 -1
- package/dist/lib/hosts/dispatch.js +52 -30
- package/dist/lib/hosts/remote-cmd.d.ts +21 -0
- package/dist/lib/hosts/remote-cmd.js +26 -2
- package/dist/lib/mailbox.d.ts +12 -0
- package/dist/lib/mailbox.js +16 -2
- package/dist/lib/menubar/snapshot.d.ts +51 -0
- package/dist/lib/menubar/snapshot.js +42 -3
- package/dist/lib/open-url.js +2 -2
- package/dist/lib/projects.d.ts +23 -0
- package/dist/lib/projects.js +78 -0
- package/dist/lib/secrets-cli.d.ts +3 -3
- package/dist/lib/secrets-cli.js +1 -1
- package/dist/lib/session/active.d.ts +1 -0
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/db.d.ts +67 -3
- package/dist/lib/session/db.js +381 -126
- package/dist/lib/session/prompt.d.ts +23 -7
- package/dist/lib/session/prompt.js +46 -8
- package/dist/lib/session/remote/remote-list.d.ts +20 -0
- package/dist/lib/session/remote/remote-list.js +22 -6
- package/dist/lib/session/remote/watch.d.ts +12 -0
- package/dist/lib/session/remote/watch.js +9 -0
- package/dist/lib/session/remote-preview-cache.d.ts +29 -0
- package/dist/lib/session/remote-preview-cache.js +373 -0
- package/dist/lib/session/tail.d.ts +50 -0
- package/dist/lib/session/tail.js +219 -0
- package/dist/lib/setup-tool-install.js +2 -1
- package/dist/lib/setup-tool-status.d.ts +1 -1
- package/dist/lib/setup-tool-status.js +6 -1
- package/dist/lib/signin-badge.d.ts +19 -4
- package/dist/lib/signin-badge.js +29 -11
- package/dist/lib/term-driver.d.ts +24 -0
- package/dist/lib/term-driver.js +36 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/index.js +1 -1
- package/dist/lib/terminal/inject.d.ts +38 -0
- package/dist/lib/terminal/inject.js +55 -9
- package/dist/lib/terminal/transport.d.ts +15 -5
- package/dist/lib/terminal/transport.js +61 -11
- package/package.json +1 -1
- package/dist/lib/fleet/remote-login.d.ts +0 -170
- package/dist/lib/fleet/remote-login.js +0 -568
package/dist/lib/session/db.js
CHANGED
|
@@ -46,8 +46,13 @@ export const SCHEMA_VERSION = 50;
|
|
|
46
46
|
* v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
|
|
47
47
|
* chat_history.jsonl prefix read filled firstUserMessage.
|
|
48
48
|
* v5 (PHNX-3939) restores first-meta Codex fork ownership, including cold files.
|
|
49
|
+
* v6 (PHNX-3999) invalidates rows whose stored `label` is harness scaffolding — a
|
|
50
|
+
* `<bash-input>` shell echo, a `<command-name>` wrapper, a bare `/clear` — which
|
|
51
|
+
* `cleanGeneratedSessionLabel` now rejects. The label is written at index time, so
|
|
52
|
+
* without this bump every already-indexed session keeps showing the junk title
|
|
53
|
+
* until its transcript happens to change.
|
|
49
54
|
*/
|
|
50
|
-
export const CONTENT_INDEX_VERSION =
|
|
55
|
+
export const CONTENT_INDEX_VERSION = 6;
|
|
51
56
|
/**
|
|
52
57
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
53
58
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -518,6 +523,40 @@ CREATE TABLE IF NOT EXISTS computer_sessions (
|
|
|
518
523
|
);
|
|
519
524
|
CREATE INDEX IF NOT EXISTS idx_computer_sessions_session ON computer_sessions(session_id);
|
|
520
525
|
CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(started_at DESC);
|
|
526
|
+
|
|
527
|
+
-- Durable requester-side cache for a REMOTE session's preview envelope
|
|
528
|
+
-- (PHNX-3999). Keyed on (normalized owning device, full session id, schema
|
|
529
|
+
-- version) rather than transcript bytes, since this box never reads the peer's
|
|
530
|
+
-- transcript directly -- it only holds the last envelope one bounded
|
|
531
|
+
-- 'sessions preview <id> --local --json' hop returned FROM that device. Content
|
|
532
|
+
-- freshness (this row) is deliberately independent of live status, which is
|
|
533
|
+
-- never cached here and always re-read from the live registry when available.
|
|
534
|
+
-- ok=1 rows carry the last successful envelope_json; ok=0 rows carry no payload,
|
|
535
|
+
-- only a failure_reason, so a session that was resolvable once but is now
|
|
536
|
+
-- offline still degrades to the last GOOD payload (read separately, ok=1 only)
|
|
537
|
+
-- annotated stale rather than losing it to a later failed attempt overwriting it.
|
|
538
|
+
CREATE TABLE IF NOT EXISTS session_remote_preview_cache (
|
|
539
|
+
device TEXT NOT NULL,
|
|
540
|
+
session_id TEXT NOT NULL,
|
|
541
|
+
schema_version INTEGER NOT NULL,
|
|
542
|
+
fetched_at INTEGER NOT NULL,
|
|
543
|
+
ok INTEGER NOT NULL,
|
|
544
|
+
envelope_json TEXT,
|
|
545
|
+
envelope_bytes INTEGER NOT NULL DEFAULT 0,
|
|
546
|
+
failure_reason TEXT,
|
|
547
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
548
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
549
|
+
-- The caller's OWN last-supplied --revision cursor (opaque: an epoch-ms
|
|
550
|
+
-- string, an ISO stamp, whatever the caller's own activity feed hands us) --
|
|
551
|
+
-- deliberately NOT compared to the envelope's own details.sourceRevision,
|
|
552
|
+
-- since a caller's revision format (e.g. a feed's lastActivityMs) need not
|
|
553
|
+
-- match the envelope's own (an ISO session.lastActivity). Equality is
|
|
554
|
+
-- against THIS column only: same value back => caller has independently
|
|
555
|
+
-- confirmed nothing changed.
|
|
556
|
+
last_caller_revision TEXT,
|
|
557
|
+
PRIMARY KEY (device, session_id, schema_version)
|
|
558
|
+
);
|
|
559
|
+
CREATE INDEX IF NOT EXISTS idx_remote_preview_cache_fetched ON session_remote_preview_cache(fetched_at DESC);
|
|
521
560
|
`;
|
|
522
561
|
/**
|
|
523
562
|
* Bumping this invalidates every cached facet row without touching the schema
|
|
@@ -532,7 +571,7 @@ export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
|
|
|
532
571
|
// Bumped to 2 (PHNX-2973): the digest now carries `changedFiles` (per-file
|
|
533
572
|
// paths). Bumping invalidates v1 cache rows so a fresh recompute populates the
|
|
534
573
|
// new field instead of serving a stale digest that predates it.
|
|
535
|
-
const PREVIEW_EXTRACTOR_VERSION =
|
|
574
|
+
const PREVIEW_EXTRACTOR_VERSION = 3;
|
|
536
575
|
/** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
|
|
537
576
|
export const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
|
|
538
577
|
/** Bump when the summarizer output shape changes so cached summaries recompute (PHNX-3939 v1). */
|
|
@@ -1404,7 +1443,7 @@ function backfillClaudeAccounts(db, scope = 'all') {
|
|
|
1404
1443
|
}
|
|
1405
1444
|
}
|
|
1406
1445
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
1407
|
-
export function getDB() {
|
|
1446
|
+
export function getDB(initialBusyTimeoutMs = 30_000) {
|
|
1408
1447
|
if (dbInstance)
|
|
1409
1448
|
return dbInstance;
|
|
1410
1449
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -1416,134 +1455,165 @@ export function getDB() {
|
|
|
1416
1455
|
// a new version home can take longer than 10s; concurrent callers need enough
|
|
1417
1456
|
// headroom to wait. The ledger-recheck in upsertSessionsBatch makes
|
|
1418
1457
|
// subsequent writers near-instant, so 30s is a rarely-reached safety net.
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
const lockedVersion = readSchemaVersion();
|
|
1437
|
-
if (lockedVersion === undefined || lockedVersion >= SCHEMA_VERSION)
|
|
1438
|
-
return;
|
|
1439
|
-
migrateSchema(db, lockedVersion);
|
|
1440
|
-
db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
|
|
1441
|
-
});
|
|
1442
|
-
migrate();
|
|
1443
|
-
}
|
|
1444
|
-
// Index last_activity only after the column is guaranteed to exist — fresh DBs
|
|
1445
|
-
// get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
|
|
1446
|
-
// run. It must NOT live in SCHEMA (executed before migration) or an existing
|
|
1447
|
-
// DB would fail the index build on a column it doesn't have yet.
|
|
1448
|
-
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
1449
|
-
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
|
|
1450
|
-
// Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
|
|
1451
|
-
// CREATE TABLE, existing from migration v46), so index the mirror pruner's
|
|
1452
|
-
// scan column here rather than in SCHEMA (PHNX-3792).
|
|
1453
|
-
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
|
|
1454
|
-
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
|
|
1455
|
-
const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
|
|
1456
|
-
if (['account_id', 'phoenix_id'].some(name => !sessionColumns.some(column => column.name === name))) {
|
|
1457
|
-
// Partial upgrades can stamp the current version before every column exists.
|
|
1458
|
-
// Recheck under the writer lock so concurrent openers cannot add it twice.
|
|
1459
|
-
db.transaction(() => {
|
|
1460
|
-
const columns = db.prepare('PRAGMA table_info(sessions)').all();
|
|
1461
|
-
if (!columns.some(column => column.name === 'account_id'))
|
|
1462
|
-
db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
|
|
1463
|
-
if (!columns.some(column => column.name === 'phoenix_id'))
|
|
1464
|
-
db.exec('ALTER TABLE sessions ADD COLUMN phoenix_id TEXT');
|
|
1465
|
-
})();
|
|
1466
|
-
}
|
|
1467
|
-
// Account attribution repair. Two ways a Claude row ends up wrong even at v33:
|
|
1468
|
-
// an older CLI (whose INSERT does not name the column) writes NULL, and a DB
|
|
1469
|
-
// migrated by a build that predates the "clear the stale email on a dark row" fix
|
|
1470
|
-
// keeps a known-wrong address. The v33 migration cannot fix either — it never runs
|
|
1471
|
-
// again. Cheap guard first so the common case is one indexed lookup, then repair.
|
|
1472
|
-
// Same shape as the `machine` repair below, for the same reason.
|
|
1473
|
-
{
|
|
1474
|
-
// Column guard FIRST, like the `machine` repair below. schema_version can be
|
|
1475
|
-
// stamped at SCHEMA_VERSION without the column existing — getDB writes the marker
|
|
1476
|
-
// for any DB whose meta has no row (a hand-built or partially-created index), and
|
|
1477
|
-
// migrateSchema never runs in that path. Querying account_key unguarded would
|
|
1478
|
-
// then throw "no such column" and take down every command that opens the index.
|
|
1479
|
-
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
1480
|
-
if (cols.some((c) => c.name === 'account_key') && cols.some((c) => c.name === 'account_org')) {
|
|
1481
|
-
const needsRepair = db.prepare(`
|
|
1482
|
-
SELECT 1 FROM sessions
|
|
1483
|
-
WHERE agent = 'claude'
|
|
1484
|
-
AND (account_key IS NULL
|
|
1485
|
-
OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))
|
|
1486
|
-
LIMIT 1
|
|
1487
|
-
`).get();
|
|
1488
|
-
if (needsRepair)
|
|
1489
|
-
db.transaction(() => backfillClaudeAccounts(db, 'unresolved'))();
|
|
1458
|
+
try {
|
|
1459
|
+
db.pragma(`busy_timeout = ${Math.max(0, Math.trunc(initialBusyTimeoutMs))}`);
|
|
1460
|
+
db.pragma('journal_mode = WAL');
|
|
1461
|
+
db.pragma('synchronous = NORMAL');
|
|
1462
|
+
db.pragma('temp_store = MEMORY');
|
|
1463
|
+
db.exec(SCHEMA);
|
|
1464
|
+
// `session_remote_preview_cache` is a lazy cache table (like the others
|
|
1465
|
+
// below), independent of SCHEMA_VERSION — but it shipped once already
|
|
1466
|
+
// without `last_caller_revision` before this column was added, so a DB
|
|
1467
|
+
// that already ran that earlier version has the table WITHOUT the column,
|
|
1468
|
+
// and `CREATE TABLE IF NOT EXISTS` above is a no-op against it. Guard with
|
|
1469
|
+
// the same PRAGMA-table_info pattern the versioned `sessions` migrations
|
|
1470
|
+
// use, so an existing cache DB gains the column instead of every read/write
|
|
1471
|
+
// throwing "no such column".
|
|
1472
|
+
const remotePreviewCacheCols = db.prepare(`PRAGMA table_info(session_remote_preview_cache)`).all();
|
|
1473
|
+
if (!remotePreviewCacheCols.some(c => c.name === 'last_caller_revision')) {
|
|
1474
|
+
db.exec(`ALTER TABLE session_remote_preview_cache ADD COLUMN last_caller_revision TEXT`);
|
|
1490
1475
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1476
|
+
const readSchemaVersion = () => {
|
|
1477
|
+
const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
|
|
1478
|
+
return row ? parseInt(row.value, 10) : undefined;
|
|
1479
|
+
};
|
|
1480
|
+
const currentVersion = readSchemaVersion();
|
|
1481
|
+
if (currentVersion === undefined) {
|
|
1482
|
+
db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
|
|
1483
|
+
}
|
|
1484
|
+
else if (currentVersion < SCHEMA_VERSION) {
|
|
1485
|
+
// Re-read after BEGIN IMMEDIATE acquires the writer lock. A second process
|
|
1486
|
+
// may have completed the migration while this connection was waiting.
|
|
1487
|
+
const migrate = db.transaction(() => {
|
|
1488
|
+
const lockedVersion = readSchemaVersion();
|
|
1489
|
+
if (lockedVersion === undefined || lockedVersion >= SCHEMA_VERSION)
|
|
1490
|
+
return;
|
|
1491
|
+
migrateSchema(db, lockedVersion);
|
|
1492
|
+
db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
|
|
1507
1493
|
});
|
|
1508
|
-
|
|
1494
|
+
migrate();
|
|
1509
1495
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
db.
|
|
1496
|
+
// Index last_activity only after the column is guaranteed to exist — fresh DBs
|
|
1497
|
+
// get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
|
|
1498
|
+
// run. It must NOT live in SCHEMA (executed before migration) or an existing
|
|
1499
|
+
// DB would fail the index build on a column it doesn't have yet.
|
|
1500
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
1501
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
|
|
1502
|
+
// Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
|
|
1503
|
+
// CREATE TABLE, existing from migration v46), so index the mirror pruner's
|
|
1504
|
+
// scan column here rather than in SCHEMA (PHNX-3792).
|
|
1505
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
|
|
1506
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
|
|
1507
|
+
const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
|
|
1508
|
+
if (['account_id', 'phoenix_id'].some(name => !sessionColumns.some(column => column.name === name))) {
|
|
1509
|
+
// Partial upgrades can stamp the current version before every column exists.
|
|
1510
|
+
// Recheck under the writer lock so concurrent openers cannot add it twice.
|
|
1511
|
+
db.transaction(() => {
|
|
1512
|
+
const columns = db.prepare('PRAGMA table_info(sessions)').all();
|
|
1513
|
+
if (!columns.some(column => column.name === 'account_id'))
|
|
1514
|
+
db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
|
|
1515
|
+
if (!columns.some(column => column.name === 'phoenix_id'))
|
|
1516
|
+
db.exec('ALTER TABLE sessions ADD COLUMN phoenix_id TEXT');
|
|
1517
|
+
})();
|
|
1526
1518
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
for
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1519
|
+
// Account attribution repair. Two ways a Claude row ends up wrong even at v33:
|
|
1520
|
+
// an older CLI (whose INSERT does not name the column) writes NULL, and a DB
|
|
1521
|
+
// migrated by a build that predates the "clear the stale email on a dark row" fix
|
|
1522
|
+
// keeps a known-wrong address. The v33 migration cannot fix either — it never runs
|
|
1523
|
+
// again. Cheap guard first so the common case is one indexed lookup, then repair.
|
|
1524
|
+
// Same shape as the `machine` repair below, for the same reason.
|
|
1525
|
+
{
|
|
1526
|
+
// Column guard FIRST, like the `machine` repair below. schema_version can be
|
|
1527
|
+
// stamped at SCHEMA_VERSION without the column existing — getDB writes the marker
|
|
1528
|
+
// for any DB whose meta has no row (a hand-built or partially-created index), and
|
|
1529
|
+
// migrateSchema never runs in that path. Querying account_key unguarded would
|
|
1530
|
+
// then throw "no such column" and take down every command that opens the index.
|
|
1531
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
1532
|
+
if (cols.some((c) => c.name === 'account_key') && cols.some((c) => c.name === 'account_org')) {
|
|
1533
|
+
const needsRepair = db.prepare(`
|
|
1534
|
+
SELECT 1 FROM sessions
|
|
1535
|
+
WHERE agent = 'claude'
|
|
1536
|
+
AND (account_key IS NULL
|
|
1537
|
+
OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))
|
|
1538
|
+
LIMIT 1
|
|
1539
|
+
`).get();
|
|
1540
|
+
if (needsRepair)
|
|
1541
|
+
db.transaction(() => backfillClaudeAccounts(db, 'unresolved'))();
|
|
1540
1542
|
}
|
|
1541
|
-
catch { /* ignore */ }
|
|
1542
1543
|
}
|
|
1543
|
-
|
|
1544
|
+
// machine column + indexes: only after the column is guaranteed present.
|
|
1545
|
+
// Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
|
|
1546
|
+
// If a partial upgrade left schema_version ahead of the column, repair here.
|
|
1547
|
+
{
|
|
1548
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
1549
|
+
if (!cols.some((c) => c.name === 'machine')) {
|
|
1550
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
|
|
1551
|
+
const rows = db
|
|
1552
|
+
.prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
|
|
1553
|
+
.all();
|
|
1554
|
+
const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
|
|
1555
|
+
const txn = db.transaction((items) => {
|
|
1556
|
+
for (const r of items) {
|
|
1557
|
+
upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
txn(rows);
|
|
1561
|
+
}
|
|
1562
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
|
|
1563
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
|
|
1564
|
+
}
|
|
1565
|
+
// harness column: only after the column is guaranteed present.
|
|
1566
|
+
// Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
|
|
1567
|
+
// schema_version can be stamped at SCHEMA_VERSION without the column existing
|
|
1568
|
+
// — getDB writes the marker for any DB whose meta has no row (a hand-built
|
|
1569
|
+
// or partially-created index), and migrateSchema never runs in that path
|
|
1570
|
+
// (`currentVersion === undefined`). If a partial upgrade left schema_version
|
|
1571
|
+
// ahead of the column, repair here so the next upsertSession INSERT naming
|
|
1572
|
+
// `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
|
|
1573
|
+
// above, for the same reason.
|
|
1574
|
+
{
|
|
1575
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
1576
|
+
if (!cols.some((c) => c.name === 'harness')) {
|
|
1577
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
|
|
1581
|
+
// them anymore. Guarded by a meta flag so we only try once.
|
|
1582
|
+
const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
|
|
1583
|
+
if (!cleaned) {
|
|
1584
|
+
for (const p of [
|
|
1585
|
+
path.join(SESSIONS_DIR, 'index.jsonl'),
|
|
1586
|
+
path.join(SESSIONS_DIR, 'content_index.jsonl'),
|
|
1587
|
+
path.join(SESSIONS_DIR, 'index.jsonl.bak'),
|
|
1588
|
+
]) {
|
|
1589
|
+
try {
|
|
1590
|
+
if (fs.existsSync(p))
|
|
1591
|
+
fs.unlinkSync(p);
|
|
1592
|
+
}
|
|
1593
|
+
catch { /* ignore */ }
|
|
1594
|
+
}
|
|
1595
|
+
db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
|
|
1596
|
+
}
|
|
1597
|
+
db.pragma('busy_timeout = 30000');
|
|
1598
|
+
dbInstance = db;
|
|
1599
|
+
return db;
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
db.close();
|
|
1603
|
+
throw error;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
/** Bound synchronous cache contention without changing other database callers. */
|
|
1607
|
+
export function withSessionDBTimeout(timeoutMs, operation) {
|
|
1608
|
+
const db = getDB(timeoutMs);
|
|
1609
|
+
const previous = db.prepare('PRAGMA busy_timeout').get().timeout;
|
|
1610
|
+
db.pragma(`busy_timeout = ${Math.max(0, Math.trunc(timeoutMs))}`);
|
|
1611
|
+
try {
|
|
1612
|
+
return operation();
|
|
1613
|
+
}
|
|
1614
|
+
finally {
|
|
1615
|
+
db.pragma(`busy_timeout = ${previous}`);
|
|
1544
1616
|
}
|
|
1545
|
-
dbInstance = db;
|
|
1546
|
-
return db;
|
|
1547
1617
|
}
|
|
1548
1618
|
/** Close the cached database connection. */
|
|
1549
1619
|
export function closeDB() {
|
|
@@ -3442,6 +3512,190 @@ export function writeSessionPreviewCache(entry) {
|
|
|
3442
3512
|
preview_json = excluded.preview_json
|
|
3443
3513
|
`).run(entry.id, entry.fileMtimeMs, entry.fileSize, PREVIEW_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.preview));
|
|
3444
3514
|
}
|
|
3515
|
+
/** Bump when the cached remote preview envelope shape changes so cached rows recompute (PHNX-3999 v1). */
|
|
3516
|
+
export const REMOTE_PREVIEW_SCHEMA_VERSION = 1;
|
|
3517
|
+
/** Cap on distinct (device, sessionId) rows this cache holds — a requester box
|
|
3518
|
+
* previews many peers' sessions over a long uptime, so this bounds growth the
|
|
3519
|
+
* same way the fleet session mirror bounds itself to 200 rows (db.ts, PHNX-3792):
|
|
3520
|
+
* oldest-by-last-fetch is evicted first. Row count alone is not a real bound —
|
|
3521
|
+
* every field the cached envelope carries is already bounded (digest fields,
|
|
3522
|
+
* `detail.messages` <= 8 x 4000 chars, `detail.timeline` <= 8 steps, etc.), but
|
|
3523
|
+
* 500 rows x an unbounded per-row size is still unbounded storage, so this is
|
|
3524
|
+
* paired with a total-byte budget below. */
|
|
3525
|
+
const REMOTE_PREVIEW_CACHE_MAX_ROWS = 500;
|
|
3526
|
+
/**
|
|
3527
|
+
* Total on-disk budget for `envelope_json` across every cached row, enforced
|
|
3528
|
+
* ALONGSIDE the row cap (whichever evicts more aggressively wins). At the
|
|
3529
|
+
* 500-row cap this is a ~32 KiB/row average, which comfortably fits the
|
|
3530
|
+
* bounded envelope shape above — a single row is refused entry above
|
|
3531
|
+
* {@link REMOTE_PREVIEW_ENVELOPE_MAX_BYTES} rather than being allowed to
|
|
3532
|
+
* consume the whole budget by itself.
|
|
3533
|
+
*/
|
|
3534
|
+
const REMOTE_PREVIEW_CACHE_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
3535
|
+
/**
|
|
3536
|
+
* Per-envelope cache-write cap. The bounded fields above make a well-formed
|
|
3537
|
+
* envelope small; this exists to refuse an outlier (a version-skewed peer, or
|
|
3538
|
+
* a future field that forgets to bound itself) rather than let ONE session
|
|
3539
|
+
* blow the total budget. A refused write is NOT an error — the caller still
|
|
3540
|
+
* gets the live envelope for this call, it simply is not persisted, so the
|
|
3541
|
+
* next call re-fetches instead of silently caching a giant blob.
|
|
3542
|
+
*/
|
|
3543
|
+
export const REMOTE_PREVIEW_ENVELOPE_MAX_BYTES = 512 * 1024;
|
|
3544
|
+
/** Read the durable cached remote preview row for one (device, sessionId) pair,
|
|
3545
|
+
* regardless of whether it currently holds a successful envelope. Undefined
|
|
3546
|
+
* means this box has never attempted (or recorded) a fetch for that pair. */
|
|
3547
|
+
export function readRemotePreviewCache(device, sessionId) {
|
|
3548
|
+
const row = getDB().prepare(`
|
|
3549
|
+
SELECT fetched_at AS fetchedAt, ok, envelope_json AS envelopeJson, failure_reason AS failureReason,
|
|
3550
|
+
consecutive_failures AS consecutiveFailures, next_attempt_at AS nextAttemptAt,
|
|
3551
|
+
last_caller_revision AS lastCallerRevision
|
|
3552
|
+
FROM session_remote_preview_cache
|
|
3553
|
+
WHERE device = ? AND session_id = ? AND schema_version = ?
|
|
3554
|
+
`).get(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION);
|
|
3555
|
+
if (!row)
|
|
3556
|
+
return undefined;
|
|
3557
|
+
let envelope;
|
|
3558
|
+
if (row.envelopeJson) {
|
|
3559
|
+
try {
|
|
3560
|
+
envelope = JSON.parse(row.envelopeJson);
|
|
3561
|
+
}
|
|
3562
|
+
catch {
|
|
3563
|
+
// Corrupt cache row: treat as no payload rather than throwing.
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
return {
|
|
3567
|
+
fetchedAt: row.fetchedAt,
|
|
3568
|
+
ok: row.ok === 1 && envelope !== undefined,
|
|
3569
|
+
envelope,
|
|
3570
|
+
failureReason: row.failureReason ?? undefined,
|
|
3571
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
3572
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
3573
|
+
lastCallerRevision: row.lastCallerRevision ?? undefined,
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* Record the caller's own `--revision` cursor for one (device, sessionId)
|
|
3578
|
+
* pair, independent of any fetch. A no-op if no row exists yet (nothing to
|
|
3579
|
+
* compare against on a session this box has never fetched). This is what lets
|
|
3580
|
+
* "same revision as last time" serve the durable cache with zero SSH
|
|
3581
|
+
* indefinitely — the comparison is against THIS value, never against the
|
|
3582
|
+
* envelope's own `details.sourceRevision` (which may be a different format).
|
|
3583
|
+
*/
|
|
3584
|
+
export function writeRemotePreviewCallerRevision(device, sessionId, revision) {
|
|
3585
|
+
getDB().prepare(`
|
|
3586
|
+
UPDATE session_remote_preview_cache
|
|
3587
|
+
SET last_caller_revision = ?
|
|
3588
|
+
WHERE device = ? AND session_id = ? AND schema_version = ?
|
|
3589
|
+
`).run(revision, device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION);
|
|
3590
|
+
}
|
|
3591
|
+
function pruneRemotePreviewCache(maxRows = REMOTE_PREVIEW_CACHE_MAX_ROWS) {
|
|
3592
|
+
const db = getDB();
|
|
3593
|
+
db.prepare(`
|
|
3594
|
+
DELETE FROM session_remote_preview_cache
|
|
3595
|
+
WHERE rowid NOT IN (
|
|
3596
|
+
SELECT rowid FROM session_remote_preview_cache ORDER BY fetched_at DESC LIMIT ?
|
|
3597
|
+
)
|
|
3598
|
+
`).run(maxRows);
|
|
3599
|
+
// Total-byte budget, independent of row count: walk newest-first, keep
|
|
3600
|
+
// rows until the running total would exceed the budget, drop the rest.
|
|
3601
|
+
const rows = db.prepare(`
|
|
3602
|
+
SELECT rowid AS rowid,
|
|
3603
|
+
envelope_bytes + length(CAST(COALESCE(failure_reason, '') AS BLOB))
|
|
3604
|
+
+ length(CAST(COALESCE(last_caller_revision, '') AS BLOB)) AS envelopeBytes
|
|
3605
|
+
FROM session_remote_preview_cache
|
|
3606
|
+
ORDER BY fetched_at DESC
|
|
3607
|
+
`).all();
|
|
3608
|
+
let total = 0;
|
|
3609
|
+
const evict = [];
|
|
3610
|
+
for (const row of rows) {
|
|
3611
|
+
total += row.envelopeBytes;
|
|
3612
|
+
if (total > REMOTE_PREVIEW_CACHE_MAX_TOTAL_BYTES)
|
|
3613
|
+
evict.push(row.rowid);
|
|
3614
|
+
}
|
|
3615
|
+
if (evict.length > 0) {
|
|
3616
|
+
const placeholders = evict.map(() => '?').join(',');
|
|
3617
|
+
db.prepare(`DELETE FROM session_remote_preview_cache WHERE rowid IN (${placeholders})`).run(...evict);
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Record a successful remote fetch: replaces the payload and resets backoff.
|
|
3622
|
+
* An envelope over {@link REMOTE_PREVIEW_ENVELOPE_MAX_BYTES} is refused —
|
|
3623
|
+
* this is a write-path bound, not a request failure: the caller already has
|
|
3624
|
+
* the live envelope for this call from the fetch that produced it, this only
|
|
3625
|
+
* decides whether it is worth persisting.
|
|
3626
|
+
*/
|
|
3627
|
+
export function writeRemotePreviewCacheSuccess(device, sessionId, envelope, fetchedAt = Date.now(), revision) {
|
|
3628
|
+
const envelopeJson = JSON.stringify(envelope);
|
|
3629
|
+
const envelopeBytes = Buffer.byteLength(envelopeJson, 'utf8');
|
|
3630
|
+
if (envelopeBytes > REMOTE_PREVIEW_ENVELOPE_MAX_BYTES)
|
|
3631
|
+
return;
|
|
3632
|
+
const db = getDB();
|
|
3633
|
+
const write = db.transaction(() => {
|
|
3634
|
+
db.prepare(`
|
|
3635
|
+
INSERT INTO session_remote_preview_cache
|
|
3636
|
+
(device, session_id, schema_version, fetched_at, ok, envelope_json, envelope_bytes, failure_reason, consecutive_failures, next_attempt_at, last_caller_revision)
|
|
3637
|
+
VALUES (?, ?, ?, ?, 1, ?, ?, NULL, 0, 0, ?)
|
|
3638
|
+
ON CONFLICT(device, session_id, schema_version) DO UPDATE SET
|
|
3639
|
+
fetched_at = excluded.fetched_at,
|
|
3640
|
+
ok = 1,
|
|
3641
|
+
envelope_json = excluded.envelope_json,
|
|
3642
|
+
envelope_bytes = excluded.envelope_bytes,
|
|
3643
|
+
failure_reason = NULL,
|
|
3644
|
+
consecutive_failures = 0,
|
|
3645
|
+
next_attempt_at = 0,
|
|
3646
|
+
last_caller_revision = excluded.last_caller_revision
|
|
3647
|
+
`).run(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION, fetchedAt, envelopeJson, envelopeBytes, revision ?? null);
|
|
3648
|
+
pruneRemotePreviewCache();
|
|
3649
|
+
});
|
|
3650
|
+
write();
|
|
3651
|
+
}
|
|
3652
|
+
/** Cap on a stored `failure_reason` string. Some reasons interpolate
|
|
3653
|
+
* peer-controlled content (`validateEnvelope`'s mismatched-id message
|
|
3654
|
+
* embeds the peer's own claimed session id) — bound it so a misbehaving or
|
|
3655
|
+
* malicious peer can't inflate a cache row's stored text unboundedly. */
|
|
3656
|
+
const REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS = 300;
|
|
3657
|
+
function boundFailureReason(reason) {
|
|
3658
|
+
return reason.length > REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS
|
|
3659
|
+
? reason.slice(0, REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS) + '…'
|
|
3660
|
+
: reason;
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* Record a failed remote fetch attempt with exponential backoff, WITHOUT
|
|
3664
|
+
* discarding a prior good envelope — a session that answered once and is now
|
|
3665
|
+
* offline still degrades to that last-good payload (read back via
|
|
3666
|
+
* {@link readRemotePreviewCache}'s `ok`/`envelope`), annotated stale by the
|
|
3667
|
+
* caller, rather than losing it the moment one attempt fails.
|
|
3668
|
+
*/
|
|
3669
|
+
export function writeRemotePreviewCacheFailure(device, sessionId, reason, backoffMs, now = Date.now()) {
|
|
3670
|
+
const boundedReason = boundFailureReason(reason);
|
|
3671
|
+
const db = getDB();
|
|
3672
|
+
const write = db.transaction(() => {
|
|
3673
|
+
const existing = readRemotePreviewCache(device, sessionId);
|
|
3674
|
+
const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
|
|
3675
|
+
const nextAttemptAt = now + backoffMs(consecutiveFailures);
|
|
3676
|
+
const keepOk = existing?.ok ? 1 : 0;
|
|
3677
|
+
const envelopeJson = existing?.ok ? JSON.stringify(existing.envelope) : null;
|
|
3678
|
+
const envelopeBytes = envelopeJson ? Buffer.byteLength(envelopeJson, 'utf8') : 0;
|
|
3679
|
+
db.prepare(`
|
|
3680
|
+
INSERT INTO session_remote_preview_cache
|
|
3681
|
+
(device, session_id, schema_version, fetched_at, ok, envelope_json, envelope_bytes, failure_reason, consecutive_failures, next_attempt_at)
|
|
3682
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3683
|
+
ON CONFLICT(device, session_id, schema_version) DO UPDATE SET
|
|
3684
|
+
ok = excluded.ok,
|
|
3685
|
+
envelope_json = excluded.envelope_json,
|
|
3686
|
+
envelope_bytes = excluded.envelope_bytes,
|
|
3687
|
+
failure_reason = excluded.failure_reason,
|
|
3688
|
+
consecutive_failures = excluded.consecutive_failures,
|
|
3689
|
+
next_attempt_at = excluded.next_attempt_at
|
|
3690
|
+
`).run(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION, existing?.fetchedAt ?? now, keepOk, envelopeJson, envelopeBytes, boundedReason, consecutiveFailures, nextAttemptAt);
|
|
3691
|
+
// A failure-only row still occupies a slot in the row/byte-bounded cache —
|
|
3692
|
+
// it must be pruned like any other write, or a persistently-offline peer's
|
|
3693
|
+
// failure rows accumulate without bound (they were previously exempt from
|
|
3694
|
+
// this call entirely).
|
|
3695
|
+
pruneRemotePreviewCache();
|
|
3696
|
+
});
|
|
3697
|
+
write();
|
|
3698
|
+
}
|
|
3445
3699
|
/**
|
|
3446
3700
|
* The session's durable user-turn text, as stored in the `session_text` FTS
|
|
3447
3701
|
* `content` column at scan time (all harnesses, keyed by session_id). This is
|
|
@@ -3579,12 +3833,13 @@ export function readSessionTimelineEntry(id) {
|
|
|
3579
3833
|
* read path. Deliberately does NOT parse the resume state: a live row needs the
|
|
3580
3834
|
* 8 steps and the request, never the fold's bookkeeping.
|
|
3581
3835
|
*/
|
|
3582
|
-
export function readSessionTimelineAny(id) {
|
|
3836
|
+
export function readSessionTimelineAny(id, stamp) {
|
|
3583
3837
|
const row = getDB().prepare(`
|
|
3584
3838
|
SELECT projection_json AS projectionJson
|
|
3585
3839
|
FROM session_timelines
|
|
3586
3840
|
WHERE session_id = ? AND extractor_version = ?
|
|
3587
|
-
|
|
3841
|
+
AND (? IS NULL OR (file_mtime_ms = ? AND file_size = ?))
|
|
3842
|
+
`).get(id, TIMELINE_EXTRACTOR_VERSION, stamp?.fileMtimeMs ?? null, stamp?.fileMtimeMs ?? null, stamp?.fileSize ?? null);
|
|
3588
3843
|
if (!row)
|
|
3589
3844
|
return undefined;
|
|
3590
3845
|
return parseTimelineProjection(row.projectionJson);
|
|
@@ -96,15 +96,31 @@ export declare function classifyUserPrompt(raw: string | undefined, opts?: {
|
|
|
96
96
|
hasImageAttachment?: boolean;
|
|
97
97
|
}): ClassifiedPrompt;
|
|
98
98
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
99
|
+
* Reduce a harness-generated session title to something worth showing as a
|
|
100
|
+
* headline, or `undefined` when it is not (PHNX-3999 F26/F27).
|
|
101
101
|
*
|
|
102
102
|
* Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
103
|
+
* FIRST turn of the transcript, so whatever the harness injected there becomes
|
|
104
|
+
* the session's name: a skill's install-directory preamble, a `!`-prefixed shell
|
|
105
|
+
* echo wrapped in `<bash-input>`, a `/clear`. The owner's recording shows both
|
|
106
|
+
* failures — one row titled with shell-command XML wrappers, others reading
|
|
107
|
+
* `/clear`. Three rungs, in order:
|
|
108
|
+
*
|
|
109
|
+
* 1. A skill invocation collapses to `/<skill>` ({@link classifyUserPrompt}
|
|
110
|
+
* reports `kind: 'skill'` only for that injected line).
|
|
111
|
+
* 2. Harness scaffolding is REJECTED, not cleaned: `<bash-input>`,
|
|
112
|
+
* `<command-name>`, `<local-command-stdout>`, `<system-reminder>` and the
|
|
113
|
+
* rest of {@link isSyntheticUserMessage}'s cross-harness list, plus a title
|
|
114
|
+
* that is only tags. Stripping the tags would leave the shell command
|
|
115
|
+
* itself as the headline, which is the same wrong answer with tidier
|
|
116
|
+
* punctuation.
|
|
117
|
+
* 3. A bare control command is rejected ({@link BARE_CONTROL_COMMAND_RE}).
|
|
118
|
+
*
|
|
119
|
+
* A rejected label returns `undefined`, so the row falls to the next rung of the
|
|
120
|
+
* canonical ladder — the daemon-generated title, then the user's own first
|
|
121
|
+
* prompt (`sessionHeadline`, `session/title.ts`). Nothing is deleted: the
|
|
122
|
+
* original turn stays on the row as `firstUserMessage` / `request`, so the
|
|
123
|
+
* details view still shows exactly what the agent was told.
|
|
108
124
|
*
|
|
109
125
|
* A user-authored title (Claude `/rename` / `custom-title`) is never passed
|
|
110
126
|
* here — the caller keeps it verbatim.
|