@phnx-labs/agents-cli 1.20.74 → 1.20.76
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 +893 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +31 -3
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +58 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +1 -32
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +37 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
|
13
13
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
14
|
const DB_PATH = getSessionsDbPath();
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
16
|
+
const SCHEMA_VERSION = 16;
|
|
17
17
|
/**
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -99,6 +99,27 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
|
|
|
99
99
|
file_path TEXT PRIMARY KEY,
|
|
100
100
|
file_mtime_ms INTEGER NOT NULL,
|
|
101
101
|
file_size INTEGER NOT NULL,
|
|
102
|
+
scanned_at INTEGER NOT NULL,
|
|
103
|
+
-- Resumable-parse cursor + continuation (B-1). parser_state is a JSON
|
|
104
|
+
-- ClaudeParserState blob (offset + accumulator snapshot) so a scan can pick
|
|
105
|
+
-- up where the last one stopped; content_text caches the accumulated user
|
|
106
|
+
-- doc so detectTicket + FTS can rebuild on append without re-reading the file.
|
|
107
|
+
-- Written by B-2; B-1 only defines + round-trips them.
|
|
108
|
+
parser_state TEXT,
|
|
109
|
+
content_text TEXT
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
-- Tracks the mtime + entry-count of every LEAF directory that directly holds
|
|
113
|
+
-- transcripts (a Claude project dir, a Gemini chats dir). A dir's mtime bumps
|
|
114
|
+
-- on create/delete/rename of its entries but NOT on an in-place append, so a
|
|
115
|
+
-- match here means the dir gained/lost/renamed no files: we can skip the
|
|
116
|
+
-- readdir + per-file stat and serve unchanged files from the DB (append-safety
|
|
117
|
+
-- is preserved by re-stat'ing only the "hot set" — see discover.ts). Keyed by
|
|
118
|
+
-- canonicalLedgerKey, same as scan_ledger.
|
|
119
|
+
CREATE TABLE IF NOT EXISTS dir_ledger (
|
|
120
|
+
dir_path TEXT PRIMARY KEY,
|
|
121
|
+
dir_mtime_ms INTEGER NOT NULL,
|
|
122
|
+
entry_count INTEGER NOT NULL,
|
|
102
123
|
scanned_at INTEGER NOT NULL
|
|
103
124
|
);
|
|
104
125
|
`;
|
|
@@ -252,6 +273,49 @@ function migrateSchema(db, fromVersion) {
|
|
|
252
273
|
db.exec(`UPDATE sessions SET origin = 'cli' WHERE origin IS NULL OR origin = ''`);
|
|
253
274
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
254
275
|
}
|
|
276
|
+
if (fromVersion < 14) {
|
|
277
|
+
// v13 → v14: the discovery scan now short-circuits the readdir + per-file
|
|
278
|
+
// stat of leaf transcript dirs whose (mtime, entry_count) is unchanged,
|
|
279
|
+
// caching that snapshot in the new `dir_ledger` table. Create it, and clear
|
|
280
|
+
// scan_ledger so the first post-upgrade scan does a clean full walk — that
|
|
281
|
+
// walk seeds BOTH ledgers correctly, so a cold/empty dir_ledger degrades to
|
|
282
|
+
// today's full behavior.
|
|
283
|
+
db.exec(`
|
|
284
|
+
CREATE TABLE IF NOT EXISTS dir_ledger (
|
|
285
|
+
dir_path TEXT PRIMARY KEY,
|
|
286
|
+
dir_mtime_ms INTEGER NOT NULL,
|
|
287
|
+
entry_count INTEGER NOT NULL,
|
|
288
|
+
scanned_at INTEGER NOT NULL
|
|
289
|
+
);
|
|
290
|
+
DELETE FROM scan_ledger;
|
|
291
|
+
`);
|
|
292
|
+
}
|
|
293
|
+
if (fromVersion < 15) {
|
|
294
|
+
// v14 → v15: the Claude scan becomes resumable (B-1). scan_ledger gains a
|
|
295
|
+
// `parser_state` continuation blob (offset + accumulator snapshot) and a
|
|
296
|
+
// `content_text` cache of the accumulated user doc. Add both columns, then
|
|
297
|
+
// clear scan_ledger so the first post-upgrade scan does a clean full walk
|
|
298
|
+
// that reseeds the cursor from byte 0.
|
|
299
|
+
const cols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
|
|
300
|
+
if (!cols.some(c => c.name === 'parser_state'))
|
|
301
|
+
db.exec(`ALTER TABLE scan_ledger ADD COLUMN parser_state TEXT`);
|
|
302
|
+
if (!cols.some(c => c.name === 'content_text'))
|
|
303
|
+
db.exec(`ALTER TABLE scan_ledger ADD COLUMN content_text TEXT`);
|
|
304
|
+
db.exec(`DELETE FROM scan_ledger;`);
|
|
305
|
+
}
|
|
306
|
+
if (fromVersion < 16) {
|
|
307
|
+
// v15 → v16: repair rows poisoned by the empty-shortId bug (now fixed at the
|
|
308
|
+
// source in deriveShortId). A session id that was only a known prefix — a bare
|
|
309
|
+
// `session_` dir, an id of exactly `api-` or `ses_` — derived to '' via
|
|
310
|
+
// `id.replace(prefix, '').slice(0, 8)`, passed the `short_id TEXT NOT NULL`
|
|
311
|
+
// column (empty string is not NULL), yet matched nothing in the
|
|
312
|
+
// `short_id LIKE ?` picker lookups. The parser no longer produces '', but
|
|
313
|
+
// existing rows don't self-heal: an empty short_id is not re-parsed unless its
|
|
314
|
+
// file changes, and an orphaned row (file gone) never re-parses at all. Repair
|
|
315
|
+
// in place — `substr(id, 1, 8)` is non-empty because `id` is the non-empty
|
|
316
|
+
// primary key — so every corrupt row becomes addressable. No rescan needed.
|
|
317
|
+
db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
|
|
318
|
+
}
|
|
255
319
|
}
|
|
256
320
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
257
321
|
export function getDB() {
|
|
@@ -310,6 +374,11 @@ export function closeDB() {
|
|
|
310
374
|
if (dbInstance) {
|
|
311
375
|
dbInstance.close();
|
|
312
376
|
dbInstance = null;
|
|
377
|
+
// Closing the connection finalizes every prepared statement it owns. Drop
|
|
378
|
+
// the cached upsert/FTS statements too, so the next getDB() rebuilds them
|
|
379
|
+
// against the fresh connection instead of re-running a finalized statement
|
|
380
|
+
// (which throws "statement has been finalized" on the first upsert).
|
|
381
|
+
cachedStmts = {};
|
|
313
382
|
}
|
|
314
383
|
}
|
|
315
384
|
// ---------------------------------------------------------------------------
|
|
@@ -440,6 +509,55 @@ export function getScanStampsForPaths(filePaths) {
|
|
|
440
509
|
}
|
|
441
510
|
return result;
|
|
442
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Bulk-load the resumable-parse continuation (parser_state + content_text) plus
|
|
514
|
+
* the stamp for a set of file paths in a single chunked query. Mirrors
|
|
515
|
+
* {@link getScanStampsForPaths}: keys by canonical path and fans results back to
|
|
516
|
+
* every original alias so callers can `.get(filePath)` with the path they passed.
|
|
517
|
+
* The Claude incremental scan uses this to fetch each changed file's prior
|
|
518
|
+
* continuation without an N+1 of {@link getScanStampByPath}.
|
|
519
|
+
*/
|
|
520
|
+
export function getParserStatesForPaths(filePaths) {
|
|
521
|
+
const result = new Map();
|
|
522
|
+
if (filePaths.length === 0)
|
|
523
|
+
return result;
|
|
524
|
+
const db = getDB();
|
|
525
|
+
const canonicalToOriginals = new Map();
|
|
526
|
+
for (const fp of filePaths) {
|
|
527
|
+
const canonical = canonicalLedgerKey(fp);
|
|
528
|
+
const aliases = canonicalToOriginals.get(canonical);
|
|
529
|
+
if (aliases)
|
|
530
|
+
aliases.push(fp);
|
|
531
|
+
else
|
|
532
|
+
canonicalToOriginals.set(canonical, [fp]);
|
|
533
|
+
}
|
|
534
|
+
const canonicalKeys = [...canonicalToOriginals.keys()];
|
|
535
|
+
const CHUNK = 500;
|
|
536
|
+
for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
|
|
537
|
+
const chunk = canonicalKeys.slice(i, i + CHUNK);
|
|
538
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
539
|
+
const rows = db
|
|
540
|
+
.prepare(`
|
|
541
|
+
SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
|
|
542
|
+
FROM scan_ledger
|
|
543
|
+
WHERE file_path IN (${placeholders})
|
|
544
|
+
`)
|
|
545
|
+
.all(...chunk);
|
|
546
|
+
for (const row of rows) {
|
|
547
|
+
const state = {
|
|
548
|
+
parserState: row.parser_state,
|
|
549
|
+
contentText: row.content_text,
|
|
550
|
+
fileMtimeMs: row.file_mtime_ms,
|
|
551
|
+
fileSize: row.file_size,
|
|
552
|
+
scannedAt: row.scanned_at,
|
|
553
|
+
};
|
|
554
|
+
for (const original of canonicalToOriginals.get(row.file_path) || []) {
|
|
555
|
+
result.set(original, state);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
443
561
|
/**
|
|
444
562
|
* Record scan stamps for files we've looked at. Covers both files that produced
|
|
445
563
|
* a session and files we looked at but chose not to index (e.g. malformed).
|
|
@@ -464,6 +582,73 @@ export function recordScans(entries) {
|
|
|
464
582
|
});
|
|
465
583
|
txn(entries);
|
|
466
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Bulk-load the dir ledger for a set of leaf directories in a single SQL query.
|
|
587
|
+
* Mirrors {@link getScanStampsForPaths}: keys by canonical path (so a dir
|
|
588
|
+
* reachable via a symlinked version home and its realpath collapse to one row)
|
|
589
|
+
* and fans the result back out to every original alias so callers can
|
|
590
|
+
* `.get(dirPath)` with the path they passed in.
|
|
591
|
+
*/
|
|
592
|
+
export function getDirLedgerForPaths(dirs) {
|
|
593
|
+
const result = new Map();
|
|
594
|
+
if (dirs.length === 0)
|
|
595
|
+
return result;
|
|
596
|
+
const db = getDB();
|
|
597
|
+
const canonicalToOriginals = new Map();
|
|
598
|
+
for (const d of dirs) {
|
|
599
|
+
const canonical = canonicalLedgerKey(d);
|
|
600
|
+
const aliases = canonicalToOriginals.get(canonical);
|
|
601
|
+
if (aliases)
|
|
602
|
+
aliases.push(d);
|
|
603
|
+
else
|
|
604
|
+
canonicalToOriginals.set(canonical, [d]);
|
|
605
|
+
}
|
|
606
|
+
const canonicalKeys = [...canonicalToOriginals.keys()];
|
|
607
|
+
const CHUNK = 500;
|
|
608
|
+
for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
|
|
609
|
+
const chunk = canonicalKeys.slice(i, i + CHUNK);
|
|
610
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
611
|
+
const rows = db
|
|
612
|
+
.prepare(`
|
|
613
|
+
SELECT dir_path, dir_mtime_ms, entry_count
|
|
614
|
+
FROM dir_ledger
|
|
615
|
+
WHERE dir_path IN (${placeholders})
|
|
616
|
+
`)
|
|
617
|
+
.all(...chunk);
|
|
618
|
+
for (const row of rows) {
|
|
619
|
+
const stamp = { dirMtimeMs: row.dir_mtime_ms, entryCount: row.entry_count };
|
|
620
|
+
for (const original of canonicalToOriginals.get(row.dir_path) || []) {
|
|
621
|
+
result.set(original, stamp);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Upsert dir-scan stamps. Recorded after a full readdir of a leaf transcript
|
|
629
|
+
* dir so the next scan can skip that dir when its (mtime, entry_count) is
|
|
630
|
+
* unchanged. Mirrors {@link recordScans}.
|
|
631
|
+
*/
|
|
632
|
+
export function recordDirScans(entries) {
|
|
633
|
+
if (entries.length === 0)
|
|
634
|
+
return;
|
|
635
|
+
const db = getDB();
|
|
636
|
+
const stmt = db.prepare(`
|
|
637
|
+
INSERT INTO dir_ledger (dir_path, dir_mtime_ms, entry_count, scanned_at)
|
|
638
|
+
VALUES (?, ?, ?, ?)
|
|
639
|
+
ON CONFLICT(dir_path) DO UPDATE SET
|
|
640
|
+
dir_mtime_ms = excluded.dir_mtime_ms,
|
|
641
|
+
entry_count = excluded.entry_count,
|
|
642
|
+
scanned_at = excluded.scanned_at
|
|
643
|
+
`);
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
const txn = db.transaction((items) => {
|
|
646
|
+
for (const { dirPath, dirMtimeMs, entryCount } of items) {
|
|
647
|
+
stmt.run(canonicalLedgerKey(dirPath), dirMtimeMs, entryCount, now);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
txn(entries);
|
|
651
|
+
}
|
|
467
652
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
468
653
|
INSERT INTO sessions (
|
|
469
654
|
id, short_id, agent, origin, routine_name, routine_run_id,
|
|
@@ -494,7 +679,15 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
494
679
|
cwd = excluded.cwd,
|
|
495
680
|
git_branch = excluded.git_branch,
|
|
496
681
|
topic = excluded.topic,
|
|
497
|
-
label
|
|
682
|
+
-- Never let an empty/placeholder incoming label clobber a good stored one.
|
|
683
|
+
-- A real incoming label (non-empty after trim) still wins; a blank one keeps
|
|
684
|
+
-- the label seeded by --name (seedLabelsFromNames) or refined by an agent
|
|
685
|
+
-- title / rename (syncLabels). A bare rescan carries no label, so it must
|
|
686
|
+
-- preserve, not erase, the good one already stored.
|
|
687
|
+
label = CASE
|
|
688
|
+
WHEN excluded.label IS NULL OR trim(excluded.label) = '' THEN sessions.label
|
|
689
|
+
ELSE excluded.label
|
|
690
|
+
END,
|
|
498
691
|
message_count = excluded.message_count,
|
|
499
692
|
token_count = excluded.token_count,
|
|
500
693
|
output_tokens = excluded.output_tokens,
|
|
@@ -513,6 +706,10 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
513
706
|
`);
|
|
514
707
|
const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
|
|
515
708
|
const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
|
|
709
|
+
// Read back the label the upsert actually stored (which may be the preserved
|
|
710
|
+
// one, not the incoming blank) so the FTS label column stays consistent with
|
|
711
|
+
// sessions.label after a bare rescan.
|
|
712
|
+
const readLabelStmt = (db) => db.prepare(`SELECT label FROM sessions WHERE id = ?`);
|
|
516
713
|
let cachedStmts = {};
|
|
517
714
|
function stmts(db) {
|
|
518
715
|
if (!cachedStmts.upsert) {
|
|
@@ -520,17 +717,28 @@ function stmts(db) {
|
|
|
520
717
|
upsert: upsertSessionStmt(db),
|
|
521
718
|
delText: deleteTextStmt(db),
|
|
522
719
|
insText: insertTextStmt(db),
|
|
720
|
+
readLabel: readLabelStmt(db),
|
|
523
721
|
};
|
|
524
722
|
}
|
|
525
723
|
return cachedStmts;
|
|
526
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Return the label stored for a session, as text for the FTS index (never NULL).
|
|
727
|
+
* Called inside the upsert transaction, AFTER the row upsert, so it reflects the
|
|
728
|
+
* preserve-non-empty-label rule in the ON CONFLICT clause rather than the raw
|
|
729
|
+
* incoming label.
|
|
730
|
+
*/
|
|
731
|
+
function storedFtsLabel(readLabel, id) {
|
|
732
|
+
const row = readLabel.get(id);
|
|
733
|
+
return row?.label ?? '';
|
|
734
|
+
}
|
|
527
735
|
/**
|
|
528
736
|
* Upsert a session row and replace its FTS5 content in a single transaction.
|
|
529
737
|
* `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
|
|
530
738
|
*/
|
|
531
739
|
export function upsertSession(meta, content, scan) {
|
|
532
740
|
const db = getDB();
|
|
533
|
-
const { upsert, delText, insText } = stmts(db);
|
|
741
|
+
const { upsert, delText, insText, readLabel } = stmts(db);
|
|
534
742
|
const row = {
|
|
535
743
|
id: meta.id,
|
|
536
744
|
short_id: meta.shortId,
|
|
@@ -566,7 +774,10 @@ export function upsertSession(meta, content, scan) {
|
|
|
566
774
|
const txn = db.transaction(() => {
|
|
567
775
|
upsert.run(row);
|
|
568
776
|
delText.run(meta.id);
|
|
569
|
-
insText.run(meta.id,
|
|
777
|
+
insText.run(meta.id,
|
|
778
|
+
// Use the label the upsert actually stored (preserve-non-empty rule),
|
|
779
|
+
// not the raw incoming one, so FTS label ranking survives a bare rescan.
|
|
780
|
+
storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
|
|
570
781
|
});
|
|
571
782
|
txn();
|
|
572
783
|
}
|
|
@@ -575,15 +786,22 @@ export function upsertSessionsBatch(entries) {
|
|
|
575
786
|
if (entries.length === 0)
|
|
576
787
|
return;
|
|
577
788
|
const db = getDB();
|
|
578
|
-
const { upsert, delText, insText } = stmts(db);
|
|
789
|
+
const { upsert, delText, insText, readLabel } = stmts(db);
|
|
579
790
|
const now = Date.now();
|
|
791
|
+
// Persist the Claude resumable-parse continuation (parser_state + content_text)
|
|
792
|
+
// alongside the stamp. On a full/incremental Claude parse the caller passes the
|
|
793
|
+
// serialized newState + accumulated user doc so the NEXT scan can resume from
|
|
794
|
+
// the persisted offset (B-2). Other scanners pass neither, leaving both columns
|
|
795
|
+
// NULL exactly as before — their ledger rows are unaffected.
|
|
580
796
|
const ledger = db.prepare(`
|
|
581
|
-
INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
|
|
582
|
-
VALUES (?, ?, ?, ?)
|
|
797
|
+
INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
|
|
798
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
583
799
|
ON CONFLICT(file_path) DO UPDATE SET
|
|
584
800
|
file_mtime_ms = excluded.file_mtime_ms,
|
|
585
801
|
file_size = excluded.file_size,
|
|
586
|
-
scanned_at = excluded.scanned_at
|
|
802
|
+
scanned_at = excluded.scanned_at,
|
|
803
|
+
parser_state = excluded.parser_state,
|
|
804
|
+
content_text = excluded.content_text
|
|
587
805
|
`);
|
|
588
806
|
// Build a lookup from canonical file path → entry, used inside the write
|
|
589
807
|
// transaction to re-check the ledger AFTER acquiring the lock. When a
|
|
@@ -613,7 +831,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
613
831
|
}
|
|
614
832
|
}
|
|
615
833
|
}
|
|
616
|
-
for (const { meta, content, scan } of items) {
|
|
834
|
+
for (const { meta, content, scan, parserState, contentText } of items) {
|
|
617
835
|
if (alreadyIndexed.has(meta.id))
|
|
618
836
|
continue;
|
|
619
837
|
// Per-row guard: one malformed session (e.g. a required field that resolves to
|
|
@@ -657,9 +875,12 @@ export function upsertSessionsBatch(entries) {
|
|
|
657
875
|
plan: meta.plan ?? null,
|
|
658
876
|
});
|
|
659
877
|
delText.run(meta.id);
|
|
660
|
-
insText.run(meta.id,
|
|
878
|
+
insText.run(meta.id,
|
|
879
|
+
// Mirror upsertSession: index the label the upsert actually stored
|
|
880
|
+
// (preserve-non-empty rule), not the raw incoming one.
|
|
881
|
+
storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
|
|
661
882
|
if (scan && meta.filePath) {
|
|
662
|
-
ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
|
|
883
|
+
ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
|
|
663
884
|
}
|
|
664
885
|
}
|
|
665
886
|
catch (err) {
|
|
@@ -909,9 +1130,12 @@ function buildSessionWhere(options) {
|
|
|
909
1130
|
params.push(options.cwd);
|
|
910
1131
|
}
|
|
911
1132
|
if (options.cwdPrefix) {
|
|
912
|
-
//
|
|
913
|
-
// the subdir wildcard must too — a hardcoded '/'
|
|
914
|
-
// `C:\a\b` subpath and the listing comes back empty.
|
|
1133
|
+
// A LOCAL stored cwd uses the host path separator (normalizeCwd runs
|
|
1134
|
+
// path.normalize on it), so the subdir wildcard must too — a hardcoded '/'
|
|
1135
|
+
// never matches a Windows `C:\a\b` subpath and the listing comes back empty.
|
|
1136
|
+
// A cwd recorded on another machine keeps its own separators, so this
|
|
1137
|
+
// wildcard does not match foreign subpaths; both sides go through
|
|
1138
|
+
// normalizeCwd, so the exact `cwd = ?` comparison still holds for them.
|
|
915
1139
|
where.push('(cwd = ? OR cwd LIKE ?)');
|
|
916
1140
|
params.push(options.cwdPrefix, options.cwdPrefix + path.sep + '%');
|
|
917
1141
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A session's foreground/background presence. */
|
|
2
|
+
export type Presence = 'attached' | 'background' | 'parked';
|
|
3
|
+
export interface DetachRecord {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
agent: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
/** pid of the detached headless continuation `agents sessions detach` spawned. */
|
|
8
|
+
headlessPid: number;
|
|
9
|
+
/**
|
|
10
|
+
* Start-time fingerprint of {@link headlessPid} at spawn, so a liveness check
|
|
11
|
+
* survives PID reuse: the pid is only "our" continuation if it still occupies
|
|
12
|
+
* the process we launched. Null when the platform capture failed.
|
|
13
|
+
*/
|
|
14
|
+
headlessStartTime: string | null;
|
|
15
|
+
detachedAtMs: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function writeDetachRecord(rec: DetachRecord): void;
|
|
18
|
+
export declare function readDetachRecord(sessionId: string): DetachRecord | undefined;
|
|
19
|
+
export declare function clearDetachRecord(sessionId: string): void;
|
|
20
|
+
export declare function listDetachRecords(): DetachRecord[];
|
|
21
|
+
/** True while the recorded headless continuation is still the live process we spawned. */
|
|
22
|
+
export declare function isHeadlessAlive(rec: DetachRecord): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Presence for a session id from the detach store alone:
|
|
25
|
+
* - no record -> undefined (caller decides: `attached` for a live
|
|
26
|
+
* interactive row, nothing for cloud/team rows)
|
|
27
|
+
* - record + pid alive -> `background` (headless continuation running)
|
|
28
|
+
* - record + pid exited -> `parked` (the run finished; transcript is durable)
|
|
29
|
+
*/
|
|
30
|
+
export declare function presenceFromStore(sessionId: string): Presence | undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached-session store — the record `agents sessions detach` writes and both
|
|
3
|
+
* `agents sessions attach` and the active-session scan read to know an agent is
|
|
4
|
+
* "backgrounded": running headless with no terminal, continuing its task
|
|
5
|
+
* unattended.
|
|
6
|
+
*
|
|
7
|
+
* One file per detached session under `~/.agents/.system/detached/<id>.json`.
|
|
8
|
+
* Presence is DERIVED, never asserted: a record only means "this session was
|
|
9
|
+
* detached to a headless continuation"; whether it is still `background`
|
|
10
|
+
* (that continuation is alive) or `parked` (it has exited) is decided live from
|
|
11
|
+
* the recorded pid + its start-time fingerprint. That keeps the store honest
|
|
12
|
+
* even across a crash that never ran `attach`.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { getSystemAgentsDir } from '../state.js';
|
|
17
|
+
import { captureProcessStartTime } from '../pty-server.js';
|
|
18
|
+
function detachedDir() {
|
|
19
|
+
return path.join(getSystemAgentsDir(), 'detached');
|
|
20
|
+
}
|
|
21
|
+
function recordPath(sessionId) {
|
|
22
|
+
return path.join(detachedDir(), `${sessionId}.json`);
|
|
23
|
+
}
|
|
24
|
+
export function writeDetachRecord(rec) {
|
|
25
|
+
fs.mkdirSync(detachedDir(), { recursive: true });
|
|
26
|
+
fs.writeFileSync(recordPath(rec.sessionId), JSON.stringify(rec, null, 2));
|
|
27
|
+
}
|
|
28
|
+
export function readDetachRecord(sessionId) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(recordPath(sessionId), 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function clearDetachRecord(sessionId) {
|
|
37
|
+
try {
|
|
38
|
+
fs.rmSync(recordPath(sessionId));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* already gone */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function listDetachRecords() {
|
|
45
|
+
let names;
|
|
46
|
+
try {
|
|
47
|
+
names = fs.readdirSync(detachedDir());
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (!name.endsWith('.json'))
|
|
55
|
+
continue;
|
|
56
|
+
const rec = readDetachRecord(name.slice(0, -'.json'.length));
|
|
57
|
+
if (rec)
|
|
58
|
+
out.push(rec);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** True while the recorded headless continuation is still the live process we spawned. */
|
|
63
|
+
export function isHeadlessAlive(rec) {
|
|
64
|
+
if (!rec.headlessPid || rec.headlessPid <= 0)
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
process.kill(rec.headlessPid, 0);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Defeat PID reuse: if the pid now belongs to a different process, it is not ours.
|
|
73
|
+
if (rec.headlessStartTime !== null) {
|
|
74
|
+
const now = captureProcessStartTime(rec.headlessPid);
|
|
75
|
+
if (now !== null && now !== rec.headlessStartTime)
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Presence for a session id from the detach store alone:
|
|
82
|
+
* - no record -> undefined (caller decides: `attached` for a live
|
|
83
|
+
* interactive row, nothing for cloud/team rows)
|
|
84
|
+
* - record + pid alive -> `background` (headless continuation running)
|
|
85
|
+
* - record + pid exited -> `parked` (the run finished; transcript is durable)
|
|
86
|
+
*/
|
|
87
|
+
export function presenceFromStore(sessionId) {
|
|
88
|
+
const rec = readDetachRecord(sessionId);
|
|
89
|
+
if (!rec)
|
|
90
|
+
return undefined;
|
|
91
|
+
return isHeadlessAlive(rec) ? 'background' : 'parked';
|
|
92
|
+
}
|