@phnx-labs/agents-cli 1.22.68 → 1.22.69
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 +30 -0
- package/README.md +14 -6
- package/dist/bootstrap.js +3 -0
- package/dist/commands/exec.js +31 -21
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +3 -0
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/share.js +43 -14
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/usage.d.ts +7 -2
- package/dist/lib/accounting/usage.js +142 -10
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +4 -0
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/monitors/engine.js +18 -0
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/run-trace-sync.d.ts +15 -0
- package/dist/lib/run-trace-sync.js +43 -21
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +12 -1
- package/dist/lib/session/db.js +20 -1
- package/dist/lib/session/discover.js +81 -1
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +7 -1
- package/dist/lib/session/prompt.js +12 -2
- package/dist/lib/session/recovery.d.ts +21 -12
- package/dist/lib/session/recovery.js +29 -11
- package/dist/lib/session/remote/watch.js +5 -2
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +13 -3
- package/dist/lib/share/publish.js +19 -15
- package/dist/lib/share/worker-template.js +5 -1
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
|
@@ -45,33 +45,55 @@ export function shouldAutoSyncTraces(disabled) {
|
|
|
45
45
|
return false; // never opted into the traces store
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Spawn a detached, unref'd `agents traces sync` and return immediately. The one
|
|
50
|
+
* place the fire-and-forget child is built, shared by the run-exit arm and the
|
|
51
|
+
* feed/notify important-post trigger.
|
|
52
|
+
*
|
|
53
|
+
* Re-invoke this CLI through getCliLaunch, NOT a hand-rolled
|
|
54
|
+
* [process.execPath, process.argv[1], …] — under the compiled standalone binary
|
|
55
|
+
* argv[1] is the bun virtual entry (/$bunfs/root/agents), which would become a
|
|
56
|
+
* bogus subcommand and silently no-op the whole feature (cli-entry.ts). The
|
|
57
|
+
* spawned `traces sync` is a fresh process with its own event loop, so its own
|
|
58
|
+
* per-request network timeouts (syncTraces) bound it.
|
|
59
|
+
*/
|
|
60
|
+
function spawnDetachedTraceSync() {
|
|
61
|
+
try {
|
|
62
|
+
const { command, args } = getCliLaunch(['traces', 'sync']);
|
|
63
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
64
|
+
// A child that never starts (ENOENT) emits an async 'error'; without a
|
|
65
|
+
// listener Node re-throws it as uncaught. Swallow — this is best-effort.
|
|
66
|
+
child.on('error', () => { });
|
|
67
|
+
child.unref();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// A synchronous spawn failure must never change the caller's outcome.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
48
73
|
/**
|
|
49
74
|
* Arm a fire-and-forget `agents traces sync` for when this process exits.
|
|
50
75
|
* No-op unless {@link shouldAutoSyncTraces} passes. Best-effort by
|
|
51
76
|
* construction: a missing binary or a stalled child never affects the run.
|
|
77
|
+
* The spawn happens in the exit handler because the upload is async work Node
|
|
78
|
+
* cannot run after `exit` — a watchdog here could never fire.
|
|
52
79
|
*/
|
|
53
80
|
export function armRunFinishTraceSync(opts = {}) {
|
|
54
81
|
if (!shouldAutoSyncTraces(!!opts.disabled))
|
|
55
82
|
return;
|
|
56
|
-
process.on('exit',
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// A synchronous spawn failure must never change the run's exit outcome.
|
|
75
|
-
}
|
|
76
|
-
});
|
|
83
|
+
process.on('exit', spawnDetachedTraceSync);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fire a fire-and-forget `agents traces sync` NOW (not on exit). An important
|
|
87
|
+
* owner-bound ping (`feed post --level important`, `agents notify`,
|
|
88
|
+
* `send --to owner`) links the caller's `…/console/sessions/<id>` page, and that
|
|
89
|
+
* page only exists once the session's shard has been uploaded — trace sync fires
|
|
90
|
+
* on run exit (PHNX-3628), not when a mid-run ping is posted. This closes that
|
|
91
|
+
* gap so the tapped link resolves instead of 404ing. No-op unless
|
|
92
|
+
* {@link shouldAutoSyncTraces} passes (signed in + already opted into the store);
|
|
93
|
+
* the incremental watermark keeps the push to essentially just this session.
|
|
94
|
+
*/
|
|
95
|
+
export function fireTraceSyncInBackground(opts = {}) {
|
|
96
|
+
if (!shouldAutoSyncTraces(!!opts.disabled))
|
|
97
|
+
return;
|
|
98
|
+
spawnDetachedTraceSync();
|
|
77
99
|
}
|
|
@@ -207,6 +207,10 @@ export declare function _resetFileStoreForTest(opts?: {
|
|
|
207
207
|
passphraseDir?: string | null;
|
|
208
208
|
passphrase?: string | null;
|
|
209
209
|
}): void;
|
|
210
|
+
/** Test-only: flush the in-memory metadata cache to disk now. Production flushes
|
|
211
|
+
* once at process exit; a test needs the on-disk copy mid-run to simulate the
|
|
212
|
+
* next `agents` process reading it. */
|
|
213
|
+
export declare function _flushMetaCacheForTest(): void;
|
|
210
214
|
/** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
|
|
211
215
|
* fails fast instead of waiting out the 30s production budget. */
|
|
212
216
|
export declare function _setFileStoreLockTimeoutForTest(ms: number | null): void;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* item and its keychain twin carry identical names:
|
|
22
22
|
* `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
|
|
23
23
|
*/
|
|
24
|
-
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
24
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'crypto';
|
|
25
25
|
import * as fs from 'fs';
|
|
26
26
|
import * as os from 'os';
|
|
27
27
|
import * as path from 'path';
|
|
@@ -210,6 +210,128 @@ export function decryptForFallback(enc, passphrase) {
|
|
|
210
210
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
211
211
|
return plaintext.toString('utf8');
|
|
212
212
|
}
|
|
213
|
+
// ---------- bundle-metadata plaintext cache (PHNX-3585) ----------
|
|
214
|
+
//
|
|
215
|
+
// Enumerating file-backed bundles — `agents secrets list`, and the
|
|
216
|
+
// account-rotation `readAccountRegistry` on the `agents run` hot path — decrypts
|
|
217
|
+
// EVERY bundle's metadata item, and each decrypt runs a fresh scrypt KDF (~12ms)
|
|
218
|
+
// because every `.enc` file carries its own random salt. On a box with dozens of
|
|
219
|
+
// bundles that is hundreds of ms of pure KDF before the harness even starts (the
|
|
220
|
+
// ~0.55s New-Claude boot cost in PHNX-3585).
|
|
221
|
+
//
|
|
222
|
+
// Bundle METADATA is non-secret by contract (secrets/bundles.ts `writeBundle`:
|
|
223
|
+
// "Metadata is non-sensitive by contract", stored no-ACL so `secrets list`
|
|
224
|
+
// enumerates without Touch ID) — the vars hold `keychain:`/`env:`/literal REFS,
|
|
225
|
+
// never the secret bytes, which live in separate `agents-cli.secrets.<bundle>.*`
|
|
226
|
+
// value items. So its decrypted JSON is safe to cache in cleartext, at the SAME
|
|
227
|
+
// 0600 protection as the ciphertext beside it.
|
|
228
|
+
//
|
|
229
|
+
// The cache keys each entry on the `.enc` file's (mtimeMs,size) plus a
|
|
230
|
+
// passphrase fingerprint, so ANY bundle write (new mtime/size) or passphrase
|
|
231
|
+
// change (new fingerprint; rotation also re-writes every file) misses and
|
|
232
|
+
// re-derives — no staleness, self-healing on any miss. It is scoped to the
|
|
233
|
+
// metadata namespace ONLY (`agents-cli.bundles.`); secret VALUE items are never
|
|
234
|
+
// read through it, so no secret is ever written to the cache.
|
|
235
|
+
const META_ITEM_PREFIX = 'agents-cli.bundles.';
|
|
236
|
+
function isMetaItem(item) {
|
|
237
|
+
return item.startsWith(META_ITEM_PREFIX);
|
|
238
|
+
}
|
|
239
|
+
let metaCache = null;
|
|
240
|
+
let metaCacheDirty = false;
|
|
241
|
+
let metaCacheFlushRegistered = false;
|
|
242
|
+
function metaCachePath() {
|
|
243
|
+
// A SIBLING of the store dir, never inside it — the same rule the store lock
|
|
244
|
+
// above follows, so a rotation's store-dir rename / `.rotate-*` sweep never
|
|
245
|
+
// moves or reaps it. Under `~/.agents/.cache/`, so it is machine-local and
|
|
246
|
+
// never enters `agents repo push/pull`.
|
|
247
|
+
return `${fileDir()}.meta-cache.json`;
|
|
248
|
+
}
|
|
249
|
+
function passphraseFingerprint() {
|
|
250
|
+
// Non-reversible tag, never the passphrase itself — so a passphrase change
|
|
251
|
+
// invalidates the cache without the key ever landing on disk in the clear.
|
|
252
|
+
return createHash('sha256').update(getPassphrase()).digest('hex').slice(0, 16);
|
|
253
|
+
}
|
|
254
|
+
function loadMetaCache() {
|
|
255
|
+
if (metaCache)
|
|
256
|
+
return metaCache;
|
|
257
|
+
const keyFp = passphraseFingerprint();
|
|
258
|
+
try {
|
|
259
|
+
const doc = JSON.parse(fs.readFileSync(metaCachePath(), 'utf8'));
|
|
260
|
+
if (doc && doc.v === 1 && doc.keyFp === keyFp && doc.entries && typeof doc.entries === 'object') {
|
|
261
|
+
metaCache = doc;
|
|
262
|
+
return metaCache;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* absent or corrupt — rebuild from scratch */
|
|
267
|
+
}
|
|
268
|
+
metaCache = { v: 1, keyFp, entries: {} };
|
|
269
|
+
return metaCache;
|
|
270
|
+
}
|
|
271
|
+
/** Decrypted metadata for `item` if the cache holds a fresh entry, else null. */
|
|
272
|
+
function metaCacheGet(item, fp) {
|
|
273
|
+
let st;
|
|
274
|
+
try {
|
|
275
|
+
st = fs.statSync(fp);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const e = loadMetaCache().entries[item];
|
|
281
|
+
return e && e.mtimeMs === st.mtimeMs && e.size === st.size ? e.plain : null;
|
|
282
|
+
}
|
|
283
|
+
/** Record freshly-decrypted metadata for `item`, flushed once at process exit.
|
|
284
|
+
* `snapshot` is the pre-readFileSync stat — the identity of the bytes just
|
|
285
|
+
* decrypted. Skip the put if a post-decrypt stat does not match: a concurrent
|
|
286
|
+
* writer during scrypt must not pin that plaintext to the new file identity. */
|
|
287
|
+
function metaCachePut(item, fp, plain, snapshot) {
|
|
288
|
+
try {
|
|
289
|
+
const now = fs.statSync(fp);
|
|
290
|
+
if (now.mtimeMs !== snapshot.mtimeMs || now.size !== snapshot.size)
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
loadMetaCache().entries[item] = { mtimeMs: snapshot.mtimeMs, size: snapshot.size, plain };
|
|
297
|
+
metaCacheDirty = true;
|
|
298
|
+
if (!metaCacheFlushRegistered) {
|
|
299
|
+
metaCacheFlushRegistered = true;
|
|
300
|
+
process.on('exit', flushMetaCache);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Drop a cached entry the moment its file is rewritten or removed, so a
|
|
304
|
+
* same-process write→read never returns pre-write metadata (mtime/size alone
|
|
305
|
+
* can collide within a millisecond on an unchanged-length rewrite). */
|
|
306
|
+
function metaCacheEvict(item) {
|
|
307
|
+
if (!isMetaItem(item))
|
|
308
|
+
return;
|
|
309
|
+
const cache = metaCache;
|
|
310
|
+
if (cache && cache.entries[item]) {
|
|
311
|
+
delete cache.entries[item];
|
|
312
|
+
metaCacheDirty = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function flushMetaCache() {
|
|
316
|
+
if (!metaCacheDirty || !metaCache)
|
|
317
|
+
return;
|
|
318
|
+
metaCacheDirty = false;
|
|
319
|
+
// Prune entries whose `.enc` file is gone so churn can't grow the cache without
|
|
320
|
+
// bound; a surviving stale entry would re-validate against mtime/size anyway.
|
|
321
|
+
for (const item of Object.keys(metaCache.entries)) {
|
|
322
|
+
if (!fs.existsSync(fileFor(item)))
|
|
323
|
+
delete metaCache.entries[item];
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
ensureFileDir();
|
|
327
|
+
const tmp = `${metaCachePath()}.${process.pid}.tmp`;
|
|
328
|
+
fs.writeFileSync(tmp, JSON.stringify(metaCache), { mode: 0o600 });
|
|
329
|
+
fs.renameSync(tmp, metaCachePath());
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* the cache is advisory — a write failure just re-derives next run */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
213
335
|
// ---------- file backend ----------
|
|
214
336
|
function fileFor(item) {
|
|
215
337
|
return path.join(fileDir(), `${item}.enc`);
|
|
@@ -226,6 +348,26 @@ function fileGet(item) {
|
|
|
226
348
|
if (!fs.existsSync(fp)) {
|
|
227
349
|
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
228
350
|
}
|
|
351
|
+
// Non-secret metadata short-circuits the scrypt decrypt when the file is
|
|
352
|
+
// unchanged (see the cache block above). Secret VALUE items skip the cache
|
|
353
|
+
// entirely, so they always decrypt fresh and never touch cleartext storage.
|
|
354
|
+
const metaItem = isMetaItem(item);
|
|
355
|
+
if (metaItem) {
|
|
356
|
+
const hit = metaCacheGet(item, fp);
|
|
357
|
+
if (hit !== null)
|
|
358
|
+
return hit;
|
|
359
|
+
}
|
|
360
|
+
// Identity of the bytes we are about to decrypt. A post-decrypt stat would
|
|
361
|
+
// attribute this plaintext to a concurrent writer's new file.
|
|
362
|
+
let snapshot;
|
|
363
|
+
if (metaItem) {
|
|
364
|
+
try {
|
|
365
|
+
snapshot = fs.statSync(fp);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
snapshot = undefined;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
229
371
|
const raw = fs.readFileSync(fp, 'utf8');
|
|
230
372
|
let parsed;
|
|
231
373
|
try {
|
|
@@ -234,12 +376,16 @@ function fileGet(item) {
|
|
|
234
376
|
catch {
|
|
235
377
|
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
236
378
|
}
|
|
379
|
+
let plain;
|
|
237
380
|
try {
|
|
238
|
-
|
|
381
|
+
plain = decryptForFallback(parsed, getPassphrase());
|
|
239
382
|
}
|
|
240
383
|
catch {
|
|
241
384
|
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
242
385
|
}
|
|
386
|
+
if (metaItem && snapshot)
|
|
387
|
+
metaCachePut(item, fp, plain, snapshot);
|
|
388
|
+
return plain;
|
|
243
389
|
}
|
|
244
390
|
function fileGetBatch(items) {
|
|
245
391
|
const out = new Map();
|
|
@@ -262,12 +408,13 @@ function fileSet(item, value) {
|
|
|
262
408
|
const enc = encryptForFallback(value, getPassphrase());
|
|
263
409
|
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
264
410
|
});
|
|
411
|
+
metaCacheEvict(item);
|
|
265
412
|
}
|
|
266
413
|
function fileDelete(item) {
|
|
267
414
|
const fp = fileFor(item);
|
|
268
415
|
if (!fs.existsSync(fp))
|
|
269
416
|
return true; // idempotent, matches secret-tool clear
|
|
270
|
-
|
|
417
|
+
const deleted = withStoreLock(() => {
|
|
271
418
|
// Re-check under the lock — a rotation may have swapped the dir since the
|
|
272
419
|
// pre-lock existence probe above.
|
|
273
420
|
if (!fs.existsSync(fp))
|
|
@@ -275,6 +422,8 @@ function fileDelete(item) {
|
|
|
275
422
|
fs.unlinkSync(fp);
|
|
276
423
|
return true;
|
|
277
424
|
});
|
|
425
|
+
metaCacheEvict(item);
|
|
426
|
+
return deleted;
|
|
278
427
|
}
|
|
279
428
|
function fileList(prefix) {
|
|
280
429
|
const dir = fileDir();
|
|
@@ -921,6 +1070,18 @@ export function _resetFileStoreForTest(opts = {}) {
|
|
|
921
1070
|
warnedAutoPassphrase = false;
|
|
922
1071
|
lockAcquireTimeoutMsOverride = null;
|
|
923
1072
|
lockStaleMsOverride = null;
|
|
1073
|
+
// Drop the in-memory metadata cache so a test that repoints the store dir or
|
|
1074
|
+
// passphrase never reads another fixture's cached plaintext (the on-disk cache
|
|
1075
|
+
// is per-dir and keyed by the passphrase fingerprint, so it self-invalidates
|
|
1076
|
+
// too, but the process-level map must be cleared explicitly).
|
|
1077
|
+
metaCache = null;
|
|
1078
|
+
metaCacheDirty = false;
|
|
1079
|
+
}
|
|
1080
|
+
/** Test-only: flush the in-memory metadata cache to disk now. Production flushes
|
|
1081
|
+
* once at process exit; a test needs the on-disk copy mid-run to simulate the
|
|
1082
|
+
* next `agents` process reading it. */
|
|
1083
|
+
export function _flushMetaCacheForTest() {
|
|
1084
|
+
flushMetaCache();
|
|
924
1085
|
}
|
|
925
1086
|
/** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
|
|
926
1087
|
* fails fast instead of waiting out the 30s production budget. */
|
|
@@ -410,6 +410,16 @@ export interface ActiveSession {
|
|
|
410
410
|
* once a session id resolves (the id is the identity then).
|
|
411
411
|
*/
|
|
412
412
|
paneId?: string;
|
|
413
|
+
/**
|
|
414
|
+
* The `ag-<agent>-<shortid>` tmux session name this row was discovered under,
|
|
415
|
+
* when it came from the tmux source. Kept even when the full session id could
|
|
416
|
+
* NOT be resolved (the row's `sessionId` is then absent), because the name's
|
|
417
|
+
* `<shortid>` suffix is the only stable selector such a row exposes — it is
|
|
418
|
+
* what `agents sessions --active` prints as the tmux target and what
|
|
419
|
+
* `agents sessions inject <shortid>` matches on to nudge an id-less remote
|
|
420
|
+
* session (PHNX-3688). Absent for non-tmux rows.
|
|
421
|
+
*/
|
|
422
|
+
tmuxName?: string;
|
|
413
423
|
}
|
|
414
424
|
export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): ActiveStatus;
|
|
415
425
|
interface ActiveQueryOptions {
|
|
@@ -1777,6 +1777,9 @@ export async function listTmuxAgentSessions() {
|
|
|
1777
1777
|
// An id-less pane keys its dedupe on the unique pane, so two anonymous
|
|
1778
1778
|
// co-located panes stay two rows instead of folding into one.
|
|
1779
1779
|
paneId: id.sessionId ?? sessionIdFromFile(sessionFile) ? undefined : pane,
|
|
1780
|
+
// The tmux session name is the one selector an id-less row still exposes —
|
|
1781
|
+
// its `<shortid>` suffix is what `sessions inject` matches on (PHNX-3688).
|
|
1782
|
+
tmuxName: sessName,
|
|
1780
1783
|
}, state, sessionFile, pidAlive));
|
|
1781
1784
|
}
|
|
1782
1785
|
return out;
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -27,8 +27,10 @@ export declare const SCHEMA_VERSION = 45;
|
|
|
27
27
|
*
|
|
28
28
|
* v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
|
|
29
29
|
* genuine first-user-turn extractor was added.
|
|
30
|
+
* v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
|
|
31
|
+
* chat_history.jsonl prefix read filled firstUserMessage.
|
|
30
32
|
*/
|
|
31
|
-
export declare const CONTENT_INDEX_VERSION =
|
|
33
|
+
export declare const CONTENT_INDEX_VERSION = 4;
|
|
32
34
|
/**
|
|
33
35
|
* Bumping this invalidates every cached facet row without touching the schema
|
|
34
36
|
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
|
@@ -623,6 +625,15 @@ export declare function findSessionMachinesByIds(ids: string[]): Map<string, str
|
|
|
623
625
|
export declare function getSessionById(id: string): SessionMeta | null;
|
|
624
626
|
/** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
|
|
625
627
|
export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
|
|
628
|
+
/**
|
|
629
|
+
* Upgrade a session-id crumb to the full indexed id. The 8-char hex `short_id`
|
|
630
|
+
* a "Sent from …" footer shows would 404 as a `…/console/sessions/<id>` URL, so
|
|
631
|
+
* an owner ping resolves it through the index first (PHNX-3698). A value that is
|
|
632
|
+
* already a full id (or any non-crumb shape, e.g. a native `ses_…` id), or a
|
|
633
|
+
* crumb the index cannot resolve, is returned unchanged — the caller keeps
|
|
634
|
+
* exactly what it had rather than getting a fabricated id.
|
|
635
|
+
*/
|
|
636
|
+
export declare function resolveFullSessionId(idOrCrumb: string | undefined): string | undefined;
|
|
626
637
|
/** Batch-resolve pane short ids; on collision the most recently active session wins. */
|
|
627
638
|
export declare function findSessionsByShortIds(shortIds: string[]): Map<string, SessionMeta>;
|
|
628
639
|
/** A single full-text search result with ranking score. */
|
package/dist/lib/session/db.js
CHANGED
|
@@ -42,8 +42,10 @@ export const SCHEMA_VERSION = 45;
|
|
|
42
42
|
*
|
|
43
43
|
* v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
|
|
44
44
|
* genuine first-user-turn extractor was added.
|
|
45
|
+
* v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
|
|
46
|
+
* chat_history.jsonl prefix read filled firstUserMessage.
|
|
45
47
|
*/
|
|
46
|
-
export const CONTENT_INDEX_VERSION =
|
|
48
|
+
export const CONTENT_INDEX_VERSION = 4;
|
|
47
49
|
/**
|
|
48
50
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
49
51
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -3711,6 +3713,23 @@ export function findSessionsById(idQuery, scope = {}) {
|
|
|
3711
3713
|
return exact;
|
|
3712
3714
|
return querySessions({ ...scope, idPrefix: q });
|
|
3713
3715
|
}
|
|
3716
|
+
/**
|
|
3717
|
+
* Upgrade a session-id crumb to the full indexed id. The 8-char hex `short_id`
|
|
3718
|
+
* a "Sent from …" footer shows would 404 as a `…/console/sessions/<id>` URL, so
|
|
3719
|
+
* an owner ping resolves it through the index first (PHNX-3698). A value that is
|
|
3720
|
+
* already a full id (or any non-crumb shape, e.g. a native `ses_…` id), or a
|
|
3721
|
+
* crumb the index cannot resolve, is returned unchanged — the caller keeps
|
|
3722
|
+
* exactly what it had rather than getting a fabricated id.
|
|
3723
|
+
*/
|
|
3724
|
+
export function resolveFullSessionId(idOrCrumb) {
|
|
3725
|
+
const id = idOrCrumb?.trim();
|
|
3726
|
+
if (!id)
|
|
3727
|
+
return undefined;
|
|
3728
|
+
if (!/^[0-9a-f]{8}$/i.test(id))
|
|
3729
|
+
return id; // already a full id (or non-hex shape) — nothing to resolve
|
|
3730
|
+
const hit = findSessionsByShortIds([id]).get(id.toLowerCase());
|
|
3731
|
+
return hit?.id ?? id;
|
|
3732
|
+
}
|
|
3714
3733
|
/** Batch-resolve pane short ids; on collision the most recently active session wins. */
|
|
3715
3734
|
export function findSessionsByShortIds(shortIds) {
|
|
3716
3735
|
const out = new Map();
|
|
@@ -2050,6 +2050,7 @@ async function scanOpenCodeIncremental(onProgress) {
|
|
|
2050
2050
|
fileSize: stat.size,
|
|
2051
2051
|
};
|
|
2052
2052
|
const prev = getScanStampByPath(OPENCODE_DB);
|
|
2053
|
+
const extractorUpgrade = prev != null && prev.extractorVersion !== CONTENT_INDEX_VERSION;
|
|
2053
2054
|
// OpenCode has a shared database rather than one transcript file per session,
|
|
2054
2055
|
// so this container-level gate must honor extractor-version invalidation too.
|
|
2055
2056
|
if (prev &&
|
|
@@ -2132,7 +2133,7 @@ async function scanOpenCodeIncremental(onProgress) {
|
|
|
2132
2133
|
) stats ON stats.session_id = s.id
|
|
2133
2134
|
WHERE s.parent_id IS NULL
|
|
2134
2135
|
ORDER BY time_created DESC
|
|
2135
|
-
LIMIT 1000;
|
|
2136
|
+
${extractorUpgrade ? '' : 'LIMIT 1000'};
|
|
2136
2137
|
`.replace(/\n/g, ' ');
|
|
2137
2138
|
const account = getOpenCodeAccount();
|
|
2138
2139
|
const rows = db.prepare(query).all();
|
|
@@ -4871,6 +4872,84 @@ async function scanGrokIncremental(onProgress) {
|
|
|
4871
4872
|
upsertSessionsBatch(scanEntries);
|
|
4872
4873
|
recordScans(touched);
|
|
4873
4874
|
}
|
|
4875
|
+
/**
|
|
4876
|
+
* Bounded read of a Grok `chat_history.jsonl` for the genuine full first user
|
|
4877
|
+
* turn (PHNX-3621 leftover from #3359). Grok's cheap scan reads only
|
|
4878
|
+
* `summary.json` (a LARGE-transcript harness whose full message log is
|
|
4879
|
+
* deliberately not parsed on the hot tick), but the first turn is at the START
|
|
4880
|
+
* of the log, so a bounded prefix read recovers it without paying the full-file
|
|
4881
|
+
* cost. Production logs lead with a `type:system` record, then a huge
|
|
4882
|
+
* `<user_info>` user dump (no `synthetic_reason`), then a `synthetic_reason`
|
|
4883
|
+
* reminder, then the originating turn at `prompt_index: 0` wrapped in
|
|
4884
|
+
* `<user_query>`. Skip that scaffolding and prefer `prompt_index` when present.
|
|
4885
|
+
* Returns undefined when no genuine user turn appears within the budget.
|
|
4886
|
+
*/
|
|
4887
|
+
function readGrokFirstUserMessage(sessionDir, maxBytes = 262_144) {
|
|
4888
|
+
const historyPath = path.join(sessionDir, 'chat_history.jsonl');
|
|
4889
|
+
let fd;
|
|
4890
|
+
try {
|
|
4891
|
+
const stat = safeStatSync(historyPath);
|
|
4892
|
+
if (!stat)
|
|
4893
|
+
return undefined;
|
|
4894
|
+
const bytesToRead = Math.min(maxBytes, stat.size);
|
|
4895
|
+
if (bytesToRead <= 0)
|
|
4896
|
+
return undefined;
|
|
4897
|
+
const buf = Buffer.allocUnsafe(bytesToRead);
|
|
4898
|
+
fd = fs.openSync(historyPath, 'r');
|
|
4899
|
+
let read = 0;
|
|
4900
|
+
while (read < bytesToRead) {
|
|
4901
|
+
const n = fs.readSync(fd, buf, read, bytesToRead - read, read);
|
|
4902
|
+
if (n <= 0)
|
|
4903
|
+
break;
|
|
4904
|
+
read += n;
|
|
4905
|
+
}
|
|
4906
|
+
const chunk = buf.subarray(0, read).toString('utf-8');
|
|
4907
|
+
// Drop a trailing partial line so we never parse half a JSON record.
|
|
4908
|
+
const lastNl = chunk.lastIndexOf('\n');
|
|
4909
|
+
const complete = lastNl === -1 ? chunk : chunk.slice(0, lastNl);
|
|
4910
|
+
let firstGenuine;
|
|
4911
|
+
for (const line of complete.split('\n')) {
|
|
4912
|
+
if (!line.trim())
|
|
4913
|
+
continue;
|
|
4914
|
+
let msg;
|
|
4915
|
+
try {
|
|
4916
|
+
msg = JSON.parse(line);
|
|
4917
|
+
}
|
|
4918
|
+
catch {
|
|
4919
|
+
continue;
|
|
4920
|
+
}
|
|
4921
|
+
if (msg?.type !== 'user')
|
|
4922
|
+
continue;
|
|
4923
|
+
if (msg.synthetic_reason)
|
|
4924
|
+
continue;
|
|
4925
|
+
const raw = msg.content;
|
|
4926
|
+
const text = typeof raw === 'string'
|
|
4927
|
+
? raw
|
|
4928
|
+
: Array.isArray(raw)
|
|
4929
|
+
? raw.map((p) => (typeof p?.text === 'string' ? p.text : '')).join('')
|
|
4930
|
+
: '';
|
|
4931
|
+
const genuine = cleanFirstUserMessage(text);
|
|
4932
|
+
if (!genuine)
|
|
4933
|
+
continue;
|
|
4934
|
+
if (msg.prompt_index != null)
|
|
4935
|
+
return genuine;
|
|
4936
|
+
if (!firstGenuine)
|
|
4937
|
+
firstGenuine = genuine;
|
|
4938
|
+
}
|
|
4939
|
+
return firstGenuine;
|
|
4940
|
+
}
|
|
4941
|
+
catch {
|
|
4942
|
+
return undefined;
|
|
4943
|
+
}
|
|
4944
|
+
finally {
|
|
4945
|
+
if (fd !== undefined) {
|
|
4946
|
+
try {
|
|
4947
|
+
fs.closeSync(fd);
|
|
4948
|
+
}
|
|
4949
|
+
catch { /* already closed / gone */ }
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4874
4953
|
/** Parse a single Grok session summary.json into session metadata. */
|
|
4875
4954
|
export function readGrokMeta(filePath, currentVersion) {
|
|
4876
4955
|
let summary;
|
|
@@ -4923,6 +5002,7 @@ export function readGrokMeta(filePath, currentVersion) {
|
|
|
4923
5002
|
filePath,
|
|
4924
5003
|
version: resolveSessionVersion('grok', filePath, embeddedVersion, currentVersion),
|
|
4925
5004
|
topic,
|
|
5005
|
+
firstUserMessage: readGrokFirstUserMessage(sessionDir),
|
|
4926
5006
|
messageCount,
|
|
4927
5007
|
};
|
|
4928
5008
|
return { meta, content: topic || '' };
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team prefixes that match the key shape but are not trackers — unit strings and
|
|
3
|
+
* common acronyms. Canonical here (the Linear module) so both the URL linkifier
|
|
4
|
+
* and the session-transcript ticket detector (`detectTicket` in `state.ts`) agree
|
|
5
|
+
* on what is a real key rather than each keeping its own copy.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LINEAR_KEY_DENYLIST: ReadonlySet<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Every distinct real Linear key mentioned in free text, in first-seen order.
|
|
10
|
+
* Honours {@link LINEAR_KEY_DENYLIST} so `UTF-8` / `X86-64` never count. Used to
|
|
11
|
+
* linkify ticket ids an owner ping only names in prose (no `session.ticketId`).
|
|
12
|
+
*/
|
|
13
|
+
export declare function linearIssueKeys(text: string | undefined): string[];
|
|
1
14
|
/** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
|
|
2
15
|
export declare function linearWorkspace(): string | undefined;
|
|
3
16
|
/** Canonical Linear issue URL for a tracker key, or undefined if unresolvable. */
|
|
@@ -16,6 +16,50 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
/** A Linear tracker key: `TEAM-N`, e.g. `RUSH-1234`. Mirrors the detector's shape. */
|
|
18
18
|
const LINEAR_KEY_RE = /^[A-Z]{2,6}-\d{1,6}$/;
|
|
19
|
+
/**
|
|
20
|
+
* The same key, matched inside free text on word boundaries. Uppercase-only team
|
|
21
|
+
* key (2–6 letters) so a lowercase `utf-8` can't masquerade as a ticket. Global
|
|
22
|
+
* so {@link linearIssueKeys} can pull every mention out of a body.
|
|
23
|
+
*/
|
|
24
|
+
const LINEAR_KEY_IN_TEXT_RE = /\b([A-Z]{2,6}-\d{1,6})\b/g;
|
|
25
|
+
/**
|
|
26
|
+
* Team prefixes that match the key shape but are not trackers — unit strings and
|
|
27
|
+
* common acronyms. Canonical here (the Linear module) so both the URL linkifier
|
|
28
|
+
* and the session-transcript ticket detector (`detectTicket` in `state.ts`) agree
|
|
29
|
+
* on what is a real key rather than each keeping its own copy.
|
|
30
|
+
*/
|
|
31
|
+
export const LINEAR_KEY_DENYLIST = new Set([
|
|
32
|
+
'UTF',
|
|
33
|
+
'SHA',
|
|
34
|
+
'ISO',
|
|
35
|
+
'RFC',
|
|
36
|
+
'IPV',
|
|
37
|
+
'X86',
|
|
38
|
+
'ARM',
|
|
39
|
+
'MP',
|
|
40
|
+
'H',
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Every distinct real Linear key mentioned in free text, in first-seen order.
|
|
44
|
+
* Honours {@link LINEAR_KEY_DENYLIST} so `UTF-8` / `X86-64` never count. Used to
|
|
45
|
+
* linkify ticket ids an owner ping only names in prose (no `session.ticketId`).
|
|
46
|
+
*/
|
|
47
|
+
export function linearIssueKeys(text) {
|
|
48
|
+
if (!text)
|
|
49
|
+
return [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const keys = [];
|
|
52
|
+
for (const m of text.matchAll(LINEAR_KEY_IN_TEXT_RE)) {
|
|
53
|
+
const key = m[1];
|
|
54
|
+
if (LINEAR_KEY_DENYLIST.has(key.split('-')[0]))
|
|
55
|
+
continue;
|
|
56
|
+
if (seen.has(key))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
keys.push(key);
|
|
60
|
+
}
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
19
63
|
// `undefined` = not yet resolved; `null` = resolved-but-unknown (skip re-reading).
|
|
20
64
|
let workspaceCache;
|
|
21
65
|
/** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
|
|
@@ -49,6 +49,7 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
49
49
|
project: active.project ?? undefined,
|
|
50
50
|
label: active.label,
|
|
51
51
|
topic: active.topic,
|
|
52
|
+
firstUserMessage: active.firstUserMessage,
|
|
52
53
|
version: active.version,
|
|
53
54
|
messageCount: undefined,
|
|
54
55
|
machine: active.machine ?? self,
|
|
@@ -10,7 +10,7 @@ import { truncate } from '../format.js';
|
|
|
10
10
|
import { sanitizeForTerminal } from '../redact.js';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import Database from '../sqlite.js';
|
|
13
|
-
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
|
|
13
|
+
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
|
|
14
14
|
import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
|
|
15
15
|
/**
|
|
16
16
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
@@ -1989,9 +1989,8 @@ export function parseCursor(filePath) {
|
|
|
1989
1989
|
* differs from the one that wrote the transcript.
|
|
1990
1990
|
*/
|
|
1991
1991
|
function parseCursorUserText(text) {
|
|
1992
|
-
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
|
|
1993
1992
|
const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
|
|
1994
|
-
return { text: (
|
|
1993
|
+
return { text: unwrapUserQuery(text), timestamp: parseCursorTimestamp(stamp) };
|
|
1995
1994
|
}
|
|
1996
1995
|
function parseCursorTimestamp(stamp) {
|
|
1997
1996
|
if (!stamp)
|
|
@@ -13,12 +13,18 @@ import type { SessionEvent } from './types.js';
|
|
|
13
13
|
* own scaffolding at parse time simply never reach here.
|
|
14
14
|
*/
|
|
15
15
|
export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Inner text of a `<user_query>` wrapper, or the original string if none.
|
|
18
|
+
* Same extraction Cursor already applies in `parseCursorUserText`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function unwrapUserQuery(text: string): string;
|
|
16
21
|
/**
|
|
17
22
|
* Return one genuine user turn in full.
|
|
18
23
|
* Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
|
|
19
24
|
* preamble can never become the session's displayed request. Unlike the topic
|
|
20
25
|
* cleaner, this intentionally preserves internal whitespace and every line of
|
|
21
|
-
* the user's actual request.
|
|
26
|
+
* the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
|
|
27
|
+
* so the stored turn is the originating request, not the harness tags.
|
|
22
28
|
*/
|
|
23
29
|
export declare function cleanFirstUserMessage(raw: string | undefined): string | undefined;
|
|
24
30
|
/** First genuine user message from an already-normalized event stream. */
|