@phnx-labs/agents-cli 1.22.68 → 1.22.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -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
- return decryptForFallback(parsed, getPassphrase());
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
- return withStoreLock(() => {
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;
@@ -15,6 +15,7 @@ import * as os from 'os';
15
15
  import * as yaml from 'yaml';
16
16
  import { deriveShortId } from '../text/short-id.js';
17
17
  import { getCacheDir } from '../state.js';
18
+ import { isRushSessionExpired } from '../rush-session.js';
18
19
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
19
20
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
21
  const CLOUD_CACHE_DIR = path.join(getCacheDir(), 'cloud-runs');
@@ -30,7 +31,7 @@ function readToken() {
30
31
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
31
32
  }
32
33
  const expiresAt = data.session?.expires_at;
33
- if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
34
+ if (isRushSessionExpired(expiresAt)) {
34
35
  const expiredAt = new Date(expiresAt * 1000).toISOString();
35
36
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
36
37
  }
@@ -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 = 3;
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. */
@@ -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 = 3;
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: (query?.[1] ?? text).trim(), timestamp: parseCursorTimestamp(stamp) };
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. */
@@ -49,6 +49,7 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
49
49
  /^\s*<(?:apps|plugins|skills)_instructions>/i,
50
50
  /^\s*<recommended_plugins>/i,
51
51
  /^\s*<(?:multi_agent_mode|environment_context)>/i,
52
+ /^\s*<user_info>/i,
52
53
  /^\s*## (?:In-flight in this repo|Host & Fleet)/i,
53
54
  /^\s*Your current session id is\b/i,
54
55
  /^\s*Linear context skipped:/i,
@@ -69,17 +70,26 @@ export function isSyntheticUserMessage(raw) {
69
70
  return false;
70
71
  return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
71
72
  }
73
+ /**
74
+ * Inner text of a `<user_query>` wrapper, or the original string if none.
75
+ * Same extraction Cursor already applies in `parseCursorUserText`.
76
+ */
77
+ export function unwrapUserQuery(text) {
78
+ const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
79
+ return (query?.[1] ?? text).trim();
80
+ }
72
81
  /**
73
82
  * Return one genuine user turn in full.
74
83
  * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
75
84
  * preamble can never become the session's displayed request. Unlike the topic
76
85
  * cleaner, this intentionally preserves internal whitespace and every line of
77
- * the user's actual request.
86
+ * the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
87
+ * so the stored turn is the originating request, not the harness tags.
78
88
  */
79
89
  export function cleanFirstUserMessage(raw) {
80
90
  if (!raw || isSyntheticUserMessage(raw))
81
91
  return undefined;
82
- const clean = raw.trim();
92
+ const clean = unwrapUserQuery(raw);
83
93
  return clean || undefined;
84
94
  }
85
95
  /** First genuine user message from an already-normalized event stream. */