@phnx-labs/agents-cli 1.22.65 → 1.22.68

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 (112) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/apply.js +9 -2
  5. package/dist/commands/browser.js +46 -21
  6. package/dist/commands/daemon-test-harness.d.ts +1 -1
  7. package/dist/commands/daemon-test-harness.js +2 -2
  8. package/dist/commands/daemon.js +21 -22
  9. package/dist/commands/exec.js +40 -0
  10. package/dist/commands/monitors.js +2 -2
  11. package/dist/commands/repo.js +57 -19
  12. package/dist/commands/resume.d.ts +16 -0
  13. package/dist/commands/resume.js +41 -8
  14. package/dist/commands/routines.js +42 -21
  15. package/dist/commands/sessions-optimize.js +1 -1
  16. package/dist/commands/sessions.js +4 -11
  17. package/dist/commands/share.d.ts +5 -3
  18. package/dist/commands/share.js +33 -9
  19. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  20. package/dist/lib/accounting/account-pool-collect.js +6 -4
  21. package/dist/lib/accounting/usage-ingest.js +4 -2
  22. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  23. package/dist/lib/accounting/usage-sync.js +66 -210
  24. package/dist/lib/accounting/usage.d.ts +11 -3
  25. package/dist/lib/accounting/usage.js +23 -10
  26. package/dist/lib/auth-health.d.ts +8 -0
  27. package/dist/lib/auth-health.js +4 -4
  28. package/dist/lib/browser/caller-identity.d.ts +12 -0
  29. package/dist/lib/browser/caller-identity.js +19 -0
  30. package/dist/lib/browser/ipc.d.ts +37 -32
  31. package/dist/lib/browser/ipc.js +146 -94
  32. package/dist/lib/browser/task-index.d.ts +10 -2
  33. package/dist/lib/browser/task-index.js +22 -3
  34. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  35. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  36. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  37. package/dist/lib/daemon/auth-sync-service.js +17 -6
  38. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  39. package/dist/lib/daemon/catchup-service.js +51 -0
  40. package/dist/lib/daemon/daemon.d.ts +12 -22
  41. package/dist/lib/daemon/daemon.js +463 -176
  42. package/dist/lib/daemon/runner.js +2 -0
  43. package/dist/lib/daemon/service.d.ts +22 -4
  44. package/dist/lib/daemon/service.js +2 -2
  45. package/dist/lib/daemon/supervisor.d.ts +55 -15
  46. package/dist/lib/daemon/supervisor.js +119 -29
  47. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  48. package/dist/lib/daemon/usage-sync-service.js +22 -18
  49. package/dist/lib/daemon-health.js +36 -31
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +12 -2
  52. package/dist/lib/daemon-ticks.d.ts +9 -6
  53. package/dist/lib/daemon-ticks.js +14 -8
  54. package/dist/lib/devices/known-hosts.d.ts +15 -0
  55. package/dist/lib/devices/known-hosts.js +18 -0
  56. package/dist/lib/devices/registry.js +2 -0
  57. package/dist/lib/exec.js +11 -0
  58. package/dist/lib/feed/watch.d.ts +3 -0
  59. package/dist/lib/feed/watch.js +13 -3
  60. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  61. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  62. package/dist/lib/fleet-shared-state.d.ts +38 -0
  63. package/dist/lib/fleet-shared-state.js +105 -0
  64. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  65. package/dist/lib/hosts/remote-cmd.js +12 -3
  66. package/dist/lib/lock-compromise.d.ts +8 -0
  67. package/dist/lib/lock-compromise.js +12 -0
  68. package/dist/lib/monitors/engine.d.ts +2 -1
  69. package/dist/lib/monitors/engine.js +9 -2
  70. package/dist/lib/refresh-coordinator.js +2 -0
  71. package/dist/lib/run-trace-sync.d.ts +13 -0
  72. package/dist/lib/run-trace-sync.js +77 -0
  73. package/dist/lib/secrets/push.d.ts +10 -0
  74. package/dist/lib/secrets/push.js +86 -7
  75. package/dist/lib/secrets/remote.d.ts +18 -6
  76. package/dist/lib/secrets/remote.js +29 -4
  77. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  78. package/dist/lib/secrets/reserved-sync.js +120 -102
  79. package/dist/lib/session/active.d.ts +3 -1
  80. package/dist/lib/session/active.js +2 -0
  81. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  82. package/dist/lib/session/actor-sidecar.js +2 -0
  83. package/dist/lib/session/db.d.ts +5 -2
  84. package/dist/lib/session/db.js +54 -22
  85. package/dist/lib/session/discover.d.ts +12 -3
  86. package/dist/lib/session/discover.js +106 -25
  87. package/dist/lib/session/prompt.d.ts +11 -0
  88. package/dist/lib/session/prompt.js +25 -0
  89. package/dist/lib/session/recovery.d.ts +30 -2
  90. package/dist/lib/session/recovery.js +56 -4
  91. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  92. package/dist/lib/session/remote/remote-list.js +29 -4
  93. package/dist/lib/session/remote/watch.d.ts +25 -2
  94. package/dist/lib/session/remote/watch.js +185 -11
  95. package/dist/lib/session/session-cache.d.ts +2 -1
  96. package/dist/lib/session/session-cache.js +1 -0
  97. package/dist/lib/session/types.d.ts +2 -0
  98. package/dist/lib/share/publish.d.ts +55 -9
  99. package/dist/lib/share/publish.js +87 -9
  100. package/dist/lib/share/worker-template.js +100 -8
  101. package/dist/lib/ssh-exec.d.ts +2 -0
  102. package/dist/lib/ssh-exec.js +20 -4
  103. package/dist/lib/teams/registry.js +2 -0
  104. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  105. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  106. package/dist/lib/traces/sync.d.ts +7 -0
  107. package/dist/lib/traces/sync.js +9 -0
  108. package/dist/lib/usage-refresh.d.ts +8 -2
  109. package/dist/lib/usage-refresh.js +3 -3
  110. package/package.json +1 -1
  111. package/dist/lib/account-state-service.d.ts +0 -21
  112. package/dist/lib/account-state-service.js +0 -60
@@ -1,129 +1,147 @@
1
1
  /**
2
- * Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371).
2
+ * Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371/PHNX-3609).
3
3
  *
4
- * Setup-tokens drift per box when sync is manual. This module plans + executes
5
- * a push of the local file-backed `auth` bundle to registered devices that do
6
- * not yet have it, always with `--remote-backend file` and never forwarding
7
- * AGENTS_SECRETS_PASSPHRASE. Each destination auto-provisions its own
8
- * machine-local key and the push read-back-verifies decryptability.
9
- *
10
- * Double-fire: each device's daemon only writes that device's own store (the
11
- * destination). Two boxes that both have `auth` skip each other (already
12
- * present). A box without `auth` is a pull target for a provisioned peer.
13
- *
14
- * The planner is pure so tests cover every skip/push branch with no SSH.
4
+ * Every daemon publishes only a safe auth readiness verdict into its owned
5
+ * fleet-shared state file. A deterministic ready publisher reads those verdicts
6
+ * and transfers the actual bundle only to a peer reporting `missing`. Secret
7
+ * values never enter Git; the exceptional provisioning transfer remains SSH,
8
+ * now async and kill-bounded instead of blocking the daemon event loop.
15
9
  */
16
10
  import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle } from './bundles.js';
17
- import { pushBundleToHost } from './push.js';
18
- import { loadDevicesSync } from '../devices/registry.js';
11
+ import { pushBundleToHostAsync } from './push.js';
12
+ import { isDialableDevice, loadDevicesSync } from '../devices/registry.js';
19
13
  import { sshTargetFor } from '../devices/connect.js';
20
- import { isHostPinned, managedKnownHostsPath } from '../devices/known-hosts.js';
14
+ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../devices/known-hosts.js';
21
15
  import { machineId, normalizeHost } from '../session/sync/config.js';
22
- import { probeDevice } from '../fleet/apply.js';
23
- export function planAuthBundlePush(localAuthOk, devices) {
16
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
17
+ import { getUserAgentsDir } from '../state.js';
18
+ /** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
19
+ export const AUTH_SYNC_PUSH_DEADLINE_MS = 20_000;
20
+ export function planAuthBundlePush(localAuthOk, localIsPublisher, devices) {
24
21
  if (!localAuthOk) {
25
- return devices.map((d) => ({
26
- action: 'skip',
27
- device: d.name,
28
- reason: 'no local file-backed auth bundle',
29
- }));
22
+ return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'no local file-backed auth bundle' }));
23
+ }
24
+ if (!localIsPublisher) {
25
+ return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'another ready device is the elected auth publisher' }));
30
26
  }
31
- return devices.map((d) => {
32
- if (!d.reachable)
33
- return { action: 'skip', device: d.name, reason: 'unreachable' };
34
- if (!d.pinned) {
35
- return {
36
- action: 'skip',
37
- device: d.name,
38
- reason: `host key not pinned; run \`agents ssh ${d.name}\` once`,
39
- };
27
+ return devices.map((device) => {
28
+ if (!device.reachable)
29
+ return { action: 'skip', device: device.name, reason: 'unreachable' };
30
+ if (!device.pinned) {
31
+ return { action: 'skip', device: device.name, reason: `host key not pinned; run \`agents ssh ${device.name}\` once` };
40
32
  }
41
- if (d.remoteHasAuth)
42
- return { action: 'skip', device: d.name, reason: 'already present' };
43
- return { action: 'push', device: d.name };
33
+ if (device.remoteAuth === 'ready')
34
+ return { action: 'skip', device: device.name, reason: 'already present' };
35
+ if (device.remoteAuth === 'invalid')
36
+ return { action: 'skip', device: device.name, reason: 'remote auth bundle uses the wrong backend' };
37
+ if (device.remoteAuth === 'unknown') {
38
+ return { action: 'skip', device: device.name, reason: 'no shared auth verdict has arrived from this peer' };
39
+ }
40
+ return { action: 'push', device: device.name };
44
41
  });
45
42
  }
46
- /**
47
- * Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
48
- *
49
- * Presence is an own-property check on `remoteBundles` (same gate as
50
- * `decideSecretPush`): a missing listing, a parse failure, or a prototype
51
- * name must all mean "not present, so push" — never "present, so skip".
52
- */
53
- export function authPresenceFromProbe(probe) {
54
- return {
55
- reachable: probe.reachable,
56
- remoteHasAuth: !!(probe.remoteBundles &&
57
- Object.prototype.hasOwnProperty.call(probe.remoteBundles, AUTH_BUNDLE_NAME)),
58
- };
59
- }
60
- /** Production default: live SSH probe of reachability + `secrets list --json`. */
61
- export function defaultAuthSyncProbe(device) {
62
- return authPresenceFromProbe(probeDevice(device, { withSecrets: true }));
63
- }
64
43
  function defaultDevices() {
65
44
  return Object.values(loadDevicesSync());
66
45
  }
46
+ function authStatus(local) {
47
+ if (!local.exists)
48
+ return 'missing';
49
+ return local.ok ? 'ready' : 'invalid';
50
+ }
51
+ /** Publish only safe readiness metadata; useful immediately before a repo push. */
52
+ export function publishReservedAuthVerdict(options = {}) {
53
+ const local = (options.inspectLocal ?? inspectReservedAuthBundle)();
54
+ const status = authStatus(local);
55
+ const device = options.localName ?? machineId();
56
+ try {
57
+ const write = updateFleetSharedDeviceState(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
58
+ return { device, status, changed: write.changed, error: null };
59
+ }
60
+ catch (err) {
61
+ return { device, status, changed: false, error: err.message };
62
+ }
63
+ }
67
64
  /**
68
- * Push the local file-backed `auth` bundle to every pinned registered device
69
- * that does not already have it. No-op when the local bundle is missing or on
70
- * the wrong backend (doctor flags that separately).
65
+ * Publish local readiness, elect one ready source, then asynchronously provision
66
+ * only peers whose shared verdict says the bundle is missing.
71
67
  */
72
- export function syncReservedAuthBundle(deps = {}) {
73
- const inspect = deps.inspectLocal ?? inspectReservedAuthBundle;
74
- const local = inspect();
75
- const localOk = local.exists && local.ok;
76
- const localName = normalizeHost(deps.localName ?? machineId());
77
- const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localName);
68
+ export async function syncReservedAuthBundle(deps = {}) {
69
+ const result = {
70
+ publisher: null,
71
+ stateChanged: false,
72
+ pushed: [],
73
+ skipped: [],
74
+ errors: [],
75
+ };
76
+ const published = publishReservedAuthVerdict(deps);
77
+ const localStatus = published.status;
78
+ const localName = published.device;
79
+ const localNorm = normalizeHost(localName);
80
+ const root = deps.userAgentsDir ?? getUserAgentsDir();
81
+ const devices = (deps.listDevices ?? defaultDevices)().filter((device) => normalizeHost(device.name) !== localNorm);
82
+ result.stateChanged = published.changed;
83
+ if (published.error)
84
+ result.errors.push({ device: localName, message: `could not publish auth verdict: ${published.error}` });
85
+ const read = readFleetSharedDeviceStates(root);
86
+ result.errors.push(...read.errors);
87
+ const stateByDevice = new Map(read.states.map((state) => [normalizeHost(state.device), state]));
88
+ const registered = new Map(devices.map((device) => [normalizeHost(device.name), device]));
89
+ const readyPublishers = read.states
90
+ .filter((state) => {
91
+ if (state.auth?.status !== 'ready')
92
+ return false;
93
+ if (normalizeHost(state.device) === localNorm)
94
+ return localStatus === 'ready';
95
+ const device = registered.get(normalizeHost(state.device));
96
+ // Removed and known-offline device files are stale observations, not live
97
+ // executors. Letting one win election would suppress provisioning forever.
98
+ return !!device && isDialableDevice(device);
99
+ })
100
+ .map((state) => state.device);
101
+ if (localStatus === 'ready' && !readyPublishers.some((name) => normalizeHost(name) === localNorm)) {
102
+ readyPublishers.push(localName);
103
+ }
104
+ readyPublishers.sort((a, b) => normalizeHost(a).localeCompare(normalizeHost(b)));
105
+ result.publisher = readyPublishers[0] ?? null;
106
+ const localIsPublisher = result.publisher !== null && normalizeHost(result.publisher) === localNorm;
78
107
  const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
79
- const probe = deps.probe ?? defaultAuthSyncProbe;
80
- const plan = planAuthBundlePush(localOk, devices.map((d) => {
81
- // No live probe until we know a push is even possible: missing local auth
82
- // skips every device, and an unpinned host is refused before we SSH.
83
- if (!localOk) {
84
- return { name: d.name, reachable: true, pinned: pinned(d.name), remoteHasAuth: false };
85
- }
86
- const isPinnedDev = pinned(d.name);
87
- if (!isPinnedDev) {
88
- return { name: d.name, reachable: true, pinned: false, remoteHasAuth: false };
89
- }
90
- const p = probe(d);
91
- return {
92
- name: d.name,
93
- reachable: p.reachable,
94
- pinned: true,
95
- remoteHasAuth: p.remoteHasAuth,
96
- };
97
- }));
98
- const pushed = [];
99
- const skipped = [];
100
- const errors = [];
101
- const push = deps.push ?? ((bundle, host) => pushBundleToHost(bundle, host, {
108
+ const plan = planAuthBundlePush(localStatus === 'ready', localIsPublisher, devices.map((device) => ({
109
+ name: device.name,
110
+ reachable: isDialableDevice(device),
111
+ pinned: isDevicePinned(device, pinned),
112
+ remoteAuth: stateByDevice.get(normalizeHost(device.name))?.auth?.status ?? 'unknown',
113
+ })));
114
+ const byName = new Map(devices.map((device) => [device.name, device]));
115
+ const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
102
116
  remoteBackend: 'file',
103
117
  operation: 'auth-sync',
118
+ agentOnly: true,
119
+ timeoutMs: AUTH_SYNC_PUSH_DEADLINE_MS,
104
120
  }));
105
121
  const targetOf = deps.sshTarget ?? sshTargetFor;
106
- for (const item of plan) {
107
- if (item.action === 'skip') {
108
- skipped.push({ device: item.device, reason: item.reason });
109
- continue;
110
- }
111
- const profile = devices.find((d) => d.name === item.device);
112
- if (!profile) {
113
- skipped.push({ device: item.device, reason: 'not in registry' });
114
- continue;
115
- }
122
+ const outcomes = await Promise.all(plan.map(async (item) => {
123
+ if (item.action === 'skip')
124
+ return { kind: 'skip', device: item.device, message: item.reason };
125
+ const profile = byName.get(item.device);
126
+ if (!profile)
127
+ return { kind: 'skip', device: item.device, message: 'not in registry' };
116
128
  try {
117
- const host = targetOf(profile);
118
- const out = push(AUTH_BUNDLE_NAME, host);
119
- if (out.ok)
120
- pushed.push(item.device);
121
- else
122
- errors.push({ device: item.device, message: out.message });
129
+ const out = await push(AUTH_BUNDLE_NAME, targetOf(profile));
130
+ return out.ok
131
+ ? { kind: 'pushed', device: item.device, message: out.message }
132
+ : { kind: 'error', device: item.device, message: out.message };
123
133
  }
124
134
  catch (err) {
125
- errors.push({ device: item.device, message: err.message });
135
+ return { kind: 'error', device: item.device, message: err.message };
126
136
  }
137
+ }));
138
+ for (const outcome of outcomes) {
139
+ if (outcome.kind === 'pushed')
140
+ result.pushed.push(outcome.device);
141
+ else if (outcome.kind === 'skip')
142
+ result.skipped.push({ device: outcome.device, reason: outcome.message });
143
+ else
144
+ result.errors.push({ device: outcome.device, message: outcome.message });
127
145
  }
128
- return { pushed, skipped, errors };
146
+ return result;
129
147
  }
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -138,6 +138,8 @@ export interface ActiveSession {
138
138
  name?: string;
139
139
  /** First meaningful line of the initial prompt (extracted topic). */
140
140
  topic?: string;
141
+ /** Full, cleaned first genuine user turn, backfilled from the session index. */
142
+ firstUserMessage?: string;
141
143
  /**
142
144
  * The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
143
145
  * The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
@@ -149,6 +149,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
149
149
  s.account = m.account;
150
150
  if (!s.label && m.label)
151
151
  s.label = m.label;
152
+ if (!s.firstUserMessage && m.firstUserMessage)
153
+ s.firstUserMessage = m.firstUserMessage;
152
154
  if (!s.ticket && m.ticketId)
153
155
  s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
154
156
  if (!s.pr && m.prUrl)
@@ -7,6 +7,18 @@ export interface SessionActorRecord {
7
7
  initiatedBy?: 'human' | 'agent';
8
8
  /** Effective permissions mode used by the launcher. */
9
9
  mode?: SessionRunMode;
10
+ /**
11
+ * The agents-cli version-home id this session launched under (e.g. `2.1.207`,
12
+ * codex `0.146.0`) — the same namespace `listInstalledVersions` /
13
+ * `collectRunCandidates` use, so a native resume can pin the exact origin
14
+ * version. Recorded at launch by the SessionStart hook (from `AGENTS_RUN_VERSION`),
15
+ * because a harness coins its real session id only AFTER spawn — the same reason
16
+ * `mode` rides the hook rather than a spawn-time `writeSessionActorRecord`. Joined
17
+ * onto the session index at scan time so a session whose transcript carries no
18
+ * embedded/derivable version (codex's `.codex-homes/<version>/` layout) no longer
19
+ * degrades native resume to `/continue` for lack of a recorded origin (PHNX-3626).
20
+ */
21
+ version?: string;
10
22
  /**
11
23
  * Custom harness / profile name when launched via `agents run <profile>`
12
24
  * (e.g. `deepseek`). Joined onto the session index at scan time so a
@@ -40,6 +40,7 @@ function isSafeAlias(alias) {
40
40
  function hasRecordData(record) {
41
41
  return typeof record.actor === 'string'
42
42
  || typeof record.mode === 'string'
43
+ || typeof record.version === 'string'
43
44
  || typeof record.harness === 'string'
44
45
  || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
45
46
  }
@@ -84,6 +85,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
84
85
  actor: previous?.actor,
85
86
  initiatedBy: previous?.initiatedBy,
86
87
  mode: previous?.mode,
88
+ version: previous?.version,
87
89
  harness: previous?.harness,
88
90
  aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
89
91
  startedAtMs: previous?.startedAtMs ?? Date.now(),
@@ -13,7 +13,7 @@ import { type ToolScanResumePoint } from './tool-store.js';
13
13
  /** Current schema version; bumped when migrations are added. Exported so tests
14
14
  * assert against the constant instead of hardcoding a number that every bump
15
15
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
16
- export declare const SCHEMA_VERSION = 44;
16
+ export declare const SCHEMA_VERSION = 45;
17
17
  /**
18
18
  * Bump to force the content extractor (assistant-answer text, alongside the
19
19
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -24,8 +24,11 @@ export declare const SCHEMA_VERSION = 44;
24
24
  * here backfills every existing session's assistant text on its next scan
25
25
  * without a `DELETE FROM scan_ledger` (which would also throw away the
26
26
  * resumable parser_state/content_text for Claude/Codex).
27
+ *
28
+ * v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
29
+ * genuine first-user-turn extractor was added.
27
30
  */
28
- export declare const CONTENT_INDEX_VERSION = 1;
31
+ export declare const CONTENT_INDEX_VERSION = 3;
29
32
  /**
30
33
  * Bumping this invalidates every cached facet row without touching the schema
31
34
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
@@ -22,12 +22,13 @@ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTr
22
22
  import { resolveResource } from '../resources.js';
23
23
  import { discoverPlugins } from '../plugins/plugins.js';
24
24
  import { machineId } from '../machine-id.js';
25
+ import { firstUserMessageFromEvents } from './prompt.js';
25
26
  const SESSIONS_DIR = getSessionsDir();
26
27
  const DB_PATH = getSessionsDbPath();
27
28
  /** Current schema version; bumped when migrations are added. Exported so tests
28
29
  * assert against the constant instead of hardcoding a number that every bump
29
30
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 44;
31
+ export const SCHEMA_VERSION = 45;
31
32
  /**
32
33
  * Bump to force the content extractor (assistant-answer text, alongside the
33
34
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -38,8 +39,11 @@ export const SCHEMA_VERSION = 44;
38
39
  * here backfills every existing session's assistant text on its next scan
39
40
  * without a `DELETE FROM scan_ledger` (which would also throw away the
40
41
  * resumable parser_state/content_text for Claude/Codex).
42
+ *
43
+ * v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
44
+ * genuine first-user-turn extractor was added.
41
45
  */
42
- export const CONTENT_INDEX_VERSION = 1;
46
+ export const CONTENT_INDEX_VERSION = 3;
43
47
  /**
44
48
  * Bump to force `agents sessions backfill resources` to re-derive every
45
49
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -96,6 +100,7 @@ CREATE TABLE IF NOT EXISTS sessions (
96
100
  cwd TEXT,
97
101
  git_branch TEXT,
98
102
  topic TEXT,
103
+ first_user_message TEXT,
99
104
  label TEXT,
100
105
  message_count INTEGER,
101
106
  token_count INTEGER,
@@ -1231,6 +1236,15 @@ function migrateSchema(db, fromVersion) {
1231
1236
  }
1232
1237
  }
1233
1238
  }
1239
+ if (fromVersion < 45) {
1240
+ // v44 -> v45: retain the full first genuine user turn separately from the
1241
+ // one-line topic. The CONTENT_INDEX_VERSION bump re-parses every transcript
1242
+ // through its normal incremental scan path so existing rows backfill without
1243
+ // a second consumer-side transcript reader.
1244
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1245
+ if (!cols.has('first_user_message'))
1246
+ db.exec(`ALTER TABLE sessions ADD COLUMN first_user_message TEXT`);
1247
+ }
1234
1248
  }
1235
1249
  /**
1236
1250
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1567,9 +1581,11 @@ export function getDBPath() {
1567
1581
  export function getScanStampByPath(filePath) {
1568
1582
  const db = getDB();
1569
1583
  const row = db
1570
- .prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
1584
+ .prepare(`SELECT file_mtime_ms, file_size, scanned_at, extractor_version FROM scan_ledger WHERE file_path = ? LIMIT 1`)
1571
1585
  .get(canonicalLedgerKey(filePath));
1572
- return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at } : null;
1586
+ return row
1587
+ ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at, extractorVersion: row.extractor_version }
1588
+ : null;
1573
1589
  }
1574
1590
  /**
1575
1591
  * Bulk-load the stamp ledger for a set of file paths in a single SQL query.
@@ -1771,7 +1787,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1771
1787
  INSERT INTO sessions (
1772
1788
  id, short_id, agent, harness, origin, routine_name, routine_run_id,
1773
1789
  version, account, account_key, account_org, mode, timestamp, last_activity,
1774
- project, cwd, git_branch, topic, label, message_count, token_count,
1790
+ project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
1775
1791
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1776
1792
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1777
1793
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1782,7 +1798,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1782
1798
  ) VALUES (
1783
1799
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1784
1800
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1785
- @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1801
+ @project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
1786
1802
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1787
1803
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1788
1804
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1802,7 +1818,13 @@ const upsertSessionStmt = (db) => db.prepare(`
1802
1818
  origin = excluded.origin,
1803
1819
  routine_name = excluded.routine_name,
1804
1820
  routine_run_id = excluded.routine_run_id,
1805
- version = excluded.version,
1821
+ -- Origin version is write-once launch metadata, like mode/harness/actor:
1822
+ -- backfill a NULL-first row once the launch sidecar lands, and keep the
1823
+ -- recorded version across a rescan that cannot re-derive it (a codex
1824
+ -- transcript outside a versions/agent/ path, or the durable sidecar aged
1825
+ -- out) — the same COALESCE the launch-metadata columns use, so native resume
1826
+ -- stops falling back for a missing recorded origin (PHNX-3626).
1827
+ version = COALESCE(excluded.version, sessions.version),
1806
1828
  account = excluded.account,
1807
1829
  account_key = excluded.account_key,
1808
1830
  account_org = excluded.account_org,
@@ -1813,6 +1835,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1813
1835
  cwd = excluded.cwd,
1814
1836
  git_branch = excluded.git_branch,
1815
1837
  topic = excluded.topic,
1838
+ first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
1816
1839
  -- Never let an empty/placeholder incoming label clobber a good stored one.
1817
1840
  -- A real incoming label (non-empty after trim) still wins; a blank one keeps
1818
1841
  -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
@@ -2006,18 +2029,23 @@ function fanOutCounts(events, agent) {
2006
2029
  : undefined,
2007
2030
  };
2008
2031
  }
2032
+ /** Fold transcript-derived metadata that every parser can supply uniformly. */
2033
+ function enrichMetaFromEvents(meta, events) {
2034
+ return {
2035
+ ...meta,
2036
+ firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
2037
+ todos: extractTodoProgressFromEvents(events),
2038
+ recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
2039
+ ...fanOutCounts(events, meta.agent),
2040
+ };
2041
+ }
2009
2042
  function enrichCachedSessionMeta(meta) {
2010
2043
  if (!meta.filePath)
2011
2044
  return meta;
2012
2045
  try {
2013
2046
  const events = parseSession(meta.filePath, meta.agent);
2014
2047
  writeResourceUsage(meta.id, events, meta.cwd);
2015
- return {
2016
- ...meta,
2017
- todos: extractTodoProgressFromEvents(events),
2018
- recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
2019
- ...fanOutCounts(events, meta.agent),
2020
- };
2048
+ return enrichMetaFromEvents(meta, events);
2021
2049
  }
2022
2050
  catch {
2023
2051
  // Synthetic/cloud rows can intentionally name a transcript that is not local.
@@ -2081,7 +2109,11 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2081
2109
  origin: meta.origin ?? 'cli',
2082
2110
  routine_name: meta.routineName ?? null,
2083
2111
  routine_run_id: meta.routineRunId ?? null,
2084
- version: meta.version ?? null,
2112
+ // Backfill the origin version from the launch-time sidecar when the transcript
2113
+ // scan couldn't derive one (codex's `.codex-homes/<version>/` home) — the same
2114
+ // write-once join as mode/harness, so native resume stops falling back to
2115
+ // `/continue` for a missing recorded version (PHNX-3626).
2116
+ version: meta.version ?? actorRec?.version ?? null,
2085
2117
  account: meta.account ?? null,
2086
2118
  account_key: meta.accountKey ?? null,
2087
2119
  account_org: meta.accountOrg ?? null,
@@ -2092,6 +2124,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2092
2124
  cwd: meta.cwd ?? null,
2093
2125
  git_branch: meta.gitBranch ?? null,
2094
2126
  topic: meta.topic ?? null,
2127
+ first_user_message: meta.firstUserMessage ?? null,
2095
2128
  label: meta.label ?? null,
2096
2129
  message_count: meta.messageCount ?? null,
2097
2130
  token_count: meta.tokenCount ?? null,
@@ -2186,7 +2219,7 @@ export function upsertSessionsBatch(entries) {
2186
2219
  // and means "not computed yet", and the next scan that carries events fills
2187
2220
  // it. Never collapse that to 0: see fanOutCounts.
2188
2221
  return entry.events
2189
- ? { ...entry, meta: { ...entry.meta, ...fanOutCounts(entry.events, entry.meta.agent) } }
2222
+ ? { ...entry, meta: enrichMetaFromEvents(entry.meta, entry.events) }
2190
2223
  : entry;
2191
2224
  }
2192
2225
  // Harnesses whose scanners produce no events AND whose parseSession reads a
@@ -2233,12 +2266,7 @@ export function upsertSessionsBatch(entries) {
2233
2266
  const scanned = scanEventToolCalls(events, prior ?? undefined);
2234
2267
  return {
2235
2268
  ...entry,
2236
- meta: {
2237
- ...entry.meta,
2238
- todos: extractTodoProgressFromEvents(events),
2239
- recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2240
- ...fanOutCounts(events, entry.meta.agent),
2241
- },
2269
+ meta: enrichMetaFromEvents(entry.meta, events),
2242
2270
  // The CHANGED calls only. On a resume these are the newly appended tail
2243
2271
  // (append-safe upsert); on a full scan they are the whole history.
2244
2272
  toolCalls: scanned.calls,
@@ -2322,7 +2350,9 @@ export function upsertSessionsBatch(entries) {
2322
2350
  origin: meta.origin ?? 'cli',
2323
2351
  routine_name: meta.routineName ?? null,
2324
2352
  routine_run_id: meta.routineRunId ?? null,
2325
- version: meta.version ?? null,
2353
+ // Backfill the origin version from the launch-time sidecar when the scan
2354
+ // couldn't derive one — see the single-row upsert above (PHNX-3626).
2355
+ version: meta.version ?? actorIndex.get(meta.id)?.version ?? null,
2326
2356
  account: meta.account ?? null,
2327
2357
  account_key: meta.accountKey ?? null,
2328
2358
  account_org: meta.accountOrg ?? null,
@@ -2333,6 +2363,7 @@ export function upsertSessionsBatch(entries) {
2333
2363
  cwd: meta.cwd ?? null,
2334
2364
  git_branch: meta.gitBranch ?? null,
2335
2365
  topic: meta.topic ?? null,
2366
+ first_user_message: meta.firstUserMessage ?? null,
2336
2367
  label: meta.label ?? null,
2337
2368
  message_count: meta.messageCount ?? null,
2338
2369
  token_count: meta.tokenCount ?? null,
@@ -2580,6 +2611,7 @@ function rowToMeta(row) {
2580
2611
  accountOrg: row.account_org ?? undefined,
2581
2612
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
2582
2613
  topic: row.topic ?? undefined,
2614
+ firstUserMessage: row.first_user_message ?? undefined,
2583
2615
  label: row.label ?? undefined,
2584
2616
  isTeamOrigin: row.is_team_origin === 1,
2585
2617
  prUrl: row.pr_url ?? undefined,
@@ -71,6 +71,7 @@ interface ClaudeSessionScan {
71
71
  version?: string;
72
72
  model?: string;
73
73
  topic?: string;
74
+ firstUserMessage?: string;
74
75
  /** Harness-owned session name (`/rename` or Claude's generated `ai-title`). */
75
76
  label?: string;
76
77
  messageCount: number;
@@ -135,6 +136,7 @@ interface CodexSessionScan {
135
136
  version?: string;
136
137
  model?: string;
137
138
  topic?: string;
139
+ firstUserMessage?: string;
138
140
  messageCount: number;
139
141
  tokenCount?: number;
140
142
  /** Real generated (output) tokens, excluding cache-read/-write context. */
@@ -418,6 +420,7 @@ interface ClaudeParseState {
418
420
  version?: string;
419
421
  model?: string;
420
422
  topic?: string;
423
+ firstUserMessage?: string;
421
424
  customTitle?: string;
422
425
  aiTitle?: string;
423
426
  entrypoint?: string;
@@ -492,7 +495,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
492
495
  * exact even when the recent window is smaller than the true count.
493
496
  */
494
497
  export interface ClaudeParserState {
495
- v: 5;
498
+ v: 6;
496
499
  /**
497
500
  * Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
498
501
  * live in the durable blob: the resumable parser reads only new bytes, so
@@ -512,6 +515,7 @@ export interface ClaudeParserState {
512
515
  entrypoint?: string;
513
516
  firstTsMs?: number;
514
517
  topic?: string;
518
+ firstUserMessage?: string;
515
519
  customTitle?: string;
516
520
  aiTitle?: string;
517
521
  plan?: string;
@@ -629,6 +633,7 @@ interface CodexParseState {
629
633
  version?: string;
630
634
  model?: string;
631
635
  topic?: string;
636
+ firstUserMessage?: string;
632
637
  messageCount: number;
633
638
  tokenCount?: number;
634
639
  lastTotalTokenUsage?: any;
@@ -667,7 +672,7 @@ export declare function initCodexParseState(): CodexParseState;
667
672
  * finalize is identical after a resume.
668
673
  */
669
674
  export interface CodexParserState {
670
- v: 3;
675
+ v: 4;
671
676
  offset: number;
672
677
  jsonlDroppingOversizedLine?: boolean;
673
678
  sessionId?: string;
@@ -677,6 +682,7 @@ export interface CodexParserState {
677
682
  version?: string;
678
683
  model?: string;
679
684
  topic?: string;
685
+ firstUserMessage?: string;
680
686
  messageCount: number;
681
687
  tokenCount?: number;
682
688
  lastTotalTokenUsage?: any;
@@ -743,6 +749,8 @@ export declare function __codexScanBranchCountsForTest(): {
743
749
  };
744
750
  /** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
745
751
  export declare function __resetCodexScanBranchCountsForTest(): void;
752
+ /** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
753
+ export declare function extractVersionFromManagedPath(agent: SessionAgentId, sourcePath?: string): string | undefined;
746
754
  /** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
747
755
  export declare function readCursorMeta(filePath: string, currentVersion?: string): {
748
756
  meta: SessionMeta;
@@ -765,11 +773,12 @@ export declare function readKimiMeta(filePath: string, priorRow?: {
765
773
  * from `offset` + adding the appended tail's deltas equals a full parse.
766
774
  */
767
775
  export interface KimiParserState {
768
- v: 1;
776
+ v: 2;
769
777
  offset: number;
770
778
  messageCount: number;
771
779
  tokenCount: number;
772
780
  outputTokens: number;
781
+ firstUserMessage?: string;
773
782
  }
774
783
  /**
775
784
  * Incrementally parse Kimi's wire.jsonl for message-count and token counters,