@phnx-labs/agents-cli 1.22.66 → 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.
Files changed (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. 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
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: isDevicePinned(d, pinned), remoteHasAuth: false };
85
- }
86
- const isPinnedDev = isDevicePinned(d, pinned);
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` →
@@ -408,6 +410,16 @@ export interface ActiveSession {
408
410
  * once a session id resolves (the id is the identity then).
409
411
  */
410
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;
411
423
  }
412
424
  export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): ActiveStatus;
413
425
  interface ActiveQueryOptions {
@@ -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)
@@ -1775,6 +1777,9 @@ export async function listTmuxAgentSessions() {
1775
1777
  // An id-less pane keys its dedupe on the unique pane, so two anonymous
1776
1778
  // co-located panes stay two rows instead of folding into one.
1777
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,
1778
1783
  }, state, sessionFile, pidAlive));
1779
1784
  }
1780
1785
  return out;
@@ -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,13 @@ 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.
30
+ * v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
31
+ * chat_history.jsonl prefix read filled firstUserMessage.
27
32
  */
28
- export declare const CONTENT_INDEX_VERSION = 1;
33
+ export declare const CONTENT_INDEX_VERSION = 4;
29
34
  /**
30
35
  * Bumping this invalidates every cached facet row without touching the schema
31
36
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
@@ -620,6 +625,15 @@ export declare function findSessionMachinesByIds(ids: string[]): Map<string, str
620
625
  export declare function getSessionById(id: string): SessionMeta | null;
621
626
  /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
622
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;
623
637
  /** Batch-resolve pane short ids; on collision the most recently active session wins. */
624
638
  export declare function findSessionsByShortIds(shortIds: string[]): Map<string, SessionMeta>;
625
639
  /** A single full-text search result with ranking score. */
@@ -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,13 @@ 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.
45
+ * v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
46
+ * chat_history.jsonl prefix read filled firstUserMessage.
41
47
  */
42
- export const CONTENT_INDEX_VERSION = 1;
48
+ export const CONTENT_INDEX_VERSION = 4;
43
49
  /**
44
50
  * Bump to force `agents sessions backfill resources` to re-derive every
45
51
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -96,6 +102,7 @@ CREATE TABLE IF NOT EXISTS sessions (
96
102
  cwd TEXT,
97
103
  git_branch TEXT,
98
104
  topic TEXT,
105
+ first_user_message TEXT,
99
106
  label TEXT,
100
107
  message_count INTEGER,
101
108
  token_count INTEGER,
@@ -1231,6 +1238,15 @@ function migrateSchema(db, fromVersion) {
1231
1238
  }
1232
1239
  }
1233
1240
  }
1241
+ if (fromVersion < 45) {
1242
+ // v44 -> v45: retain the full first genuine user turn separately from the
1243
+ // one-line topic. The CONTENT_INDEX_VERSION bump re-parses every transcript
1244
+ // through its normal incremental scan path so existing rows backfill without
1245
+ // a second consumer-side transcript reader.
1246
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1247
+ if (!cols.has('first_user_message'))
1248
+ db.exec(`ALTER TABLE sessions ADD COLUMN first_user_message TEXT`);
1249
+ }
1234
1250
  }
1235
1251
  /**
1236
1252
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1567,9 +1583,11 @@ export function getDBPath() {
1567
1583
  export function getScanStampByPath(filePath) {
1568
1584
  const db = getDB();
1569
1585
  const row = db
1570
- .prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
1586
+ .prepare(`SELECT file_mtime_ms, file_size, scanned_at, extractor_version FROM scan_ledger WHERE file_path = ? LIMIT 1`)
1571
1587
  .get(canonicalLedgerKey(filePath));
1572
- return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at } : null;
1588
+ return row
1589
+ ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at, extractorVersion: row.extractor_version }
1590
+ : null;
1573
1591
  }
1574
1592
  /**
1575
1593
  * Bulk-load the stamp ledger for a set of file paths in a single SQL query.
@@ -1771,7 +1789,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1771
1789
  INSERT INTO sessions (
1772
1790
  id, short_id, agent, harness, origin, routine_name, routine_run_id,
1773
1791
  version, account, account_key, account_org, mode, timestamp, last_activity,
1774
- project, cwd, git_branch, topic, label, message_count, token_count,
1792
+ project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
1775
1793
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1776
1794
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1777
1795
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1782,7 +1800,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1782
1800
  ) VALUES (
1783
1801
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1784
1802
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1785
- @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1803
+ @project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
1786
1804
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1787
1805
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1788
1806
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1802,7 +1820,13 @@ const upsertSessionStmt = (db) => db.prepare(`
1802
1820
  origin = excluded.origin,
1803
1821
  routine_name = excluded.routine_name,
1804
1822
  routine_run_id = excluded.routine_run_id,
1805
- version = excluded.version,
1823
+ -- Origin version is write-once launch metadata, like mode/harness/actor:
1824
+ -- backfill a NULL-first row once the launch sidecar lands, and keep the
1825
+ -- recorded version across a rescan that cannot re-derive it (a codex
1826
+ -- transcript outside a versions/agent/ path, or the durable sidecar aged
1827
+ -- out) — the same COALESCE the launch-metadata columns use, so native resume
1828
+ -- stops falling back for a missing recorded origin (PHNX-3626).
1829
+ version = COALESCE(excluded.version, sessions.version),
1806
1830
  account = excluded.account,
1807
1831
  account_key = excluded.account_key,
1808
1832
  account_org = excluded.account_org,
@@ -1813,6 +1837,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1813
1837
  cwd = excluded.cwd,
1814
1838
  git_branch = excluded.git_branch,
1815
1839
  topic = excluded.topic,
1840
+ first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
1816
1841
  -- Never let an empty/placeholder incoming label clobber a good stored one.
1817
1842
  -- A real incoming label (non-empty after trim) still wins; a blank one keeps
1818
1843
  -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
@@ -2006,18 +2031,23 @@ function fanOutCounts(events, agent) {
2006
2031
  : undefined,
2007
2032
  };
2008
2033
  }
2034
+ /** Fold transcript-derived metadata that every parser can supply uniformly. */
2035
+ function enrichMetaFromEvents(meta, events) {
2036
+ return {
2037
+ ...meta,
2038
+ firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
2039
+ todos: extractTodoProgressFromEvents(events),
2040
+ recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
2041
+ ...fanOutCounts(events, meta.agent),
2042
+ };
2043
+ }
2009
2044
  function enrichCachedSessionMeta(meta) {
2010
2045
  if (!meta.filePath)
2011
2046
  return meta;
2012
2047
  try {
2013
2048
  const events = parseSession(meta.filePath, meta.agent);
2014
2049
  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
- };
2050
+ return enrichMetaFromEvents(meta, events);
2021
2051
  }
2022
2052
  catch {
2023
2053
  // Synthetic/cloud rows can intentionally name a transcript that is not local.
@@ -2081,7 +2111,11 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2081
2111
  origin: meta.origin ?? 'cli',
2082
2112
  routine_name: meta.routineName ?? null,
2083
2113
  routine_run_id: meta.routineRunId ?? null,
2084
- version: meta.version ?? null,
2114
+ // Backfill the origin version from the launch-time sidecar when the transcript
2115
+ // scan couldn't derive one (codex's `.codex-homes/<version>/` home) — the same
2116
+ // write-once join as mode/harness, so native resume stops falling back to
2117
+ // `/continue` for a missing recorded version (PHNX-3626).
2118
+ version: meta.version ?? actorRec?.version ?? null,
2085
2119
  account: meta.account ?? null,
2086
2120
  account_key: meta.accountKey ?? null,
2087
2121
  account_org: meta.accountOrg ?? null,
@@ -2092,6 +2126,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2092
2126
  cwd: meta.cwd ?? null,
2093
2127
  git_branch: meta.gitBranch ?? null,
2094
2128
  topic: meta.topic ?? null,
2129
+ first_user_message: meta.firstUserMessage ?? null,
2095
2130
  label: meta.label ?? null,
2096
2131
  message_count: meta.messageCount ?? null,
2097
2132
  token_count: meta.tokenCount ?? null,
@@ -2186,7 +2221,7 @@ export function upsertSessionsBatch(entries) {
2186
2221
  // and means "not computed yet", and the next scan that carries events fills
2187
2222
  // it. Never collapse that to 0: see fanOutCounts.
2188
2223
  return entry.events
2189
- ? { ...entry, meta: { ...entry.meta, ...fanOutCounts(entry.events, entry.meta.agent) } }
2224
+ ? { ...entry, meta: enrichMetaFromEvents(entry.meta, entry.events) }
2190
2225
  : entry;
2191
2226
  }
2192
2227
  // Harnesses whose scanners produce no events AND whose parseSession reads a
@@ -2233,12 +2268,7 @@ export function upsertSessionsBatch(entries) {
2233
2268
  const scanned = scanEventToolCalls(events, prior ?? undefined);
2234
2269
  return {
2235
2270
  ...entry,
2236
- meta: {
2237
- ...entry.meta,
2238
- todos: extractTodoProgressFromEvents(events),
2239
- recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2240
- ...fanOutCounts(events, entry.meta.agent),
2241
- },
2271
+ meta: enrichMetaFromEvents(entry.meta, events),
2242
2272
  // The CHANGED calls only. On a resume these are the newly appended tail
2243
2273
  // (append-safe upsert); on a full scan they are the whole history.
2244
2274
  toolCalls: scanned.calls,
@@ -2322,7 +2352,9 @@ export function upsertSessionsBatch(entries) {
2322
2352
  origin: meta.origin ?? 'cli',
2323
2353
  routine_name: meta.routineName ?? null,
2324
2354
  routine_run_id: meta.routineRunId ?? null,
2325
- version: meta.version ?? null,
2355
+ // Backfill the origin version from the launch-time sidecar when the scan
2356
+ // couldn't derive one — see the single-row upsert above (PHNX-3626).
2357
+ version: meta.version ?? actorIndex.get(meta.id)?.version ?? null,
2326
2358
  account: meta.account ?? null,
2327
2359
  account_key: meta.accountKey ?? null,
2328
2360
  account_org: meta.accountOrg ?? null,
@@ -2333,6 +2365,7 @@ export function upsertSessionsBatch(entries) {
2333
2365
  cwd: meta.cwd ?? null,
2334
2366
  git_branch: meta.gitBranch ?? null,
2335
2367
  topic: meta.topic ?? null,
2368
+ first_user_message: meta.firstUserMessage ?? null,
2336
2369
  label: meta.label ?? null,
2337
2370
  message_count: meta.messageCount ?? null,
2338
2371
  token_count: meta.tokenCount ?? null,
@@ -2580,6 +2613,7 @@ function rowToMeta(row) {
2580
2613
  accountOrg: row.account_org ?? undefined,
2581
2614
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
2582
2615
  topic: row.topic ?? undefined,
2616
+ firstUserMessage: row.first_user_message ?? undefined,
2583
2617
  label: row.label ?? undefined,
2584
2618
  isTeamOrigin: row.is_team_origin === 1,
2585
2619
  prUrl: row.pr_url ?? undefined,
@@ -3679,6 +3713,23 @@ export function findSessionsById(idQuery, scope = {}) {
3679
3713
  return exact;
3680
3714
  return querySessions({ ...scope, idPrefix: q });
3681
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
+ }
3682
3733
  /** Batch-resolve pane short ids; on collision the most recently active session wins. */
3683
3734
  export function findSessionsByShortIds(shortIds) {
3684
3735
  const out = new Map();