@phnx-labs/agents-cli 1.22.111 → 1.22.113

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/exec.d.ts +106 -1
  3. package/dist/commands/exec.js +10 -10
  4. package/dist/commands/sessions.d.ts +1 -0
  5. package/dist/commands/sessions.js +12 -0
  6. package/dist/lib/daemon/daemon.d.ts +0 -8
  7. package/dist/lib/daemon/daemon.js +41 -22
  8. package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
  9. package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
  10. package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
  11. package/dist/lib/daemon/testdata/process-view-start.js +43 -0
  12. package/dist/lib/event-provenance.d.ts +2 -0
  13. package/dist/lib/event-provenance.js +4 -0
  14. package/dist/lib/exec.js +27 -7
  15. package/dist/lib/feed/activity-stream.js +9 -0
  16. package/dist/lib/feed/activity.d.ts +8 -0
  17. package/dist/lib/feed/activity.js +61 -18
  18. package/dist/lib/feed/watch.d.ts +9 -0
  19. package/dist/lib/feed/watch.js +91 -3
  20. package/dist/lib/feed-post.js +1 -1
  21. package/dist/lib/hosts/dispatch.d.ts +0 -10
  22. package/dist/lib/hosts/dispatch.js +2 -19
  23. package/dist/lib/hosts/remote-session-id.d.ts +5 -27
  24. package/dist/lib/hosts/remote-session-id.js +18 -61
  25. package/dist/lib/launch-identity.d.ts +10 -0
  26. package/dist/lib/launch-identity.js +31 -0
  27. package/dist/lib/session/active.js +9 -3
  28. package/dist/lib/session/db.d.ts +2 -1
  29. package/dist/lib/session/db.js +39 -4
  30. package/dist/lib/session/discover.js +7 -2
  31. package/dist/lib/session/hook-sessions.d.ts +10 -0
  32. package/dist/lib/session/hook-sessions.js +139 -6
  33. package/dist/lib/session/pid-registry.d.ts +18 -5
  34. package/dist/lib/session/pid-registry.js +164 -17
  35. package/dist/lib/session/process-view.d.ts +25 -0
  36. package/dist/lib/session/process-view.js +176 -0
  37. package/dist/lib/session/projection.d.ts +13 -0
  38. package/dist/lib/session/projection.js +101 -0
  39. package/dist/lib/session/remote/watch.d.ts +8 -0
  40. package/dist/lib/session/remote/watch.js +7 -3
  41. package/dist/lib/session/session-cache.js +15 -0
  42. package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
  43. package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
  44. package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
  45. package/dist/lib/session/testdata/process-view-namespace.js +56 -0
  46. package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
  47. package/dist/lib/session/testdata/process-view-private.js +12 -0
  48. package/package.json +1 -1
@@ -198,14 +198,67 @@ function readTail(file, maxBytes) {
198
198
  // sessions or directories a long-lived consumer visits. Never expose cached
199
199
  // objects: callers enrich and mutate returned events (including nested values).
200
200
  const ACTIVITY_CACHE_BYTES = 32 * 1024 * 1024;
201
- const ACTIVITY_CACHE_FILES = 1024;
202
201
  const activityTails = new Map();
202
+ // A separate LRU of payload references lets pressure release parsed events
203
+ // without repeatedly walking thousands of summaries that cannot shrink.
204
+ const activityTailPayloads = new Map();
203
205
  let activityTailBytes = 0;
206
+ let activityTailReads = 0;
207
+ /** Read-only process-local diagnostics; no event content or file paths. */
208
+ export function getActivityCacheStats() {
209
+ return {
210
+ entries: activityTails.size,
211
+ parsedTails: activityTailPayloads.size,
212
+ bytes: activityTailBytes,
213
+ maxBytes: ACTIVITY_CACHE_BYTES,
214
+ tailReads: activityTailReads,
215
+ };
216
+ }
204
217
  function forgetActivityTail(key) {
205
218
  const old = activityTails.get(key);
206
219
  if (old)
207
220
  activityTailBytes -= old.weight;
208
221
  activityTails.delete(key);
222
+ activityTailPayloads.delete(key);
223
+ }
224
+ function activitySummaryWeight(key, stamp) {
225
+ return (key.length + stamp.length) * 2 + 256;
226
+ }
227
+ function retainActivityTail(key, result, sinceMs) {
228
+ const summaryWeight = activitySummaryWeight(key, result.stamp);
229
+ // Even metadata is byte-bounded. A tail whose summary cannot fit still
230
+ // returns normally, but must not evict the cache or be retained over budget.
231
+ if (summaryWeight > ACTIVITY_CACHE_BYTES)
232
+ return;
233
+ const retained = { ...result };
234
+ if (result.newestMs < sinceMs || result.weight > ACTIVITY_CACHE_BYTES / 2) {
235
+ retained.events = undefined;
236
+ retained.weight = summaryWeight;
237
+ }
238
+ // Prefer compact history over payloads, retaining all summaries that fit
239
+ // instead of imposing a file-count cap that thrashes on each directory scan.
240
+ for (const [oldKey, old] of activityTailPayloads) {
241
+ if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
242
+ break;
243
+ const weight = activitySummaryWeight(oldKey, old.stamp);
244
+ activityTailBytes -= old.weight - weight;
245
+ old.events = undefined;
246
+ old.weight = weight;
247
+ activityTailPayloads.delete(oldKey);
248
+ }
249
+ // If all existing entries are summaries, sacrifice the incoming payload
250
+ // before evicting history. The current caller still receives result.events.
251
+ if (activityTailBytes + retained.weight > ACTIVITY_CACHE_BYTES) {
252
+ retained.events = undefined;
253
+ retained.weight = summaryWeight;
254
+ }
255
+ while (activityTailBytes + retained.weight > ACTIVITY_CACHE_BYTES) {
256
+ forgetActivityTail(activityTails.keys().next().value);
257
+ }
258
+ activityTails.set(key, retained);
259
+ if (retained.events)
260
+ activityTailPayloads.set(key, retained);
261
+ activityTailBytes += retained.weight;
209
262
  }
210
263
  function activityStamp(file) {
211
264
  // ctime catches same-size rewrites even when a producer restores mtime;
@@ -228,9 +281,14 @@ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
228
281
  if (cached?.stamp === stamp && (cached.events || cached.newestMs < sinceMs)) {
229
282
  activityTails.delete(key);
230
283
  activityTails.set(key, cached);
284
+ if (cached.events) {
285
+ activityTailPayloads.delete(key);
286
+ activityTailPayloads.set(key, cached);
287
+ }
231
288
  return cached;
232
289
  }
233
290
  forgetActivityTail(key);
291
+ activityTailReads++;
234
292
  const text = readTail(file, maxBytes);
235
293
  // Transient I/O failure is not an empty file snapshot; retry next read.
236
294
  if (text === undefined)
@@ -256,27 +314,12 @@ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
256
314
  }
257
315
  // Bound retained source-derived strings, objects, and entry overhead. Large
258
316
  // one-off tails still read normally, but cannot displace the entire cache.
259
- const weight = text.length * 2 + events.length * 1024 + key.length * 2 + 256;
317
+ const weight = text.length * 2 + events.length * 1024 + activitySummaryWeight(key, stamp);
260
318
  const result = { stamp, events, newestMs, weight };
261
319
  try {
262
320
  // A writer racing the read must not pin a mixed snapshot as unchanged.
263
321
  if (activityStamp(file) === stamp) {
264
- if (activityTails.size >= ACTIVITY_CACHE_FILES)
265
- forgetActivityTail(activityTails.keys().next().value);
266
- const summaryWeight = key.length * 2 + 256;
267
- const retained = weight <= ACTIVITY_CACHE_BYTES / 2 ? { ...result } : { ...result, events: undefined, weight: summaryWeight };
268
- // Retain timestamp summaries even when parsed tails exceed the budget:
269
- // otherwise a large historical corpus thrashes on every cursor poll.
270
- for (const [oldKey, old] of activityTails) {
271
- if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
272
- break;
273
- const summary = oldKey.length * 2 + 256;
274
- activityTailBytes -= old.weight - summary;
275
- old.events = undefined;
276
- old.weight = summary;
277
- }
278
- activityTails.set(key, retained);
279
- activityTailBytes += retained.weight;
322
+ retainActivityTail(key, result, sinceMs);
280
323
  }
281
324
  }
282
325
  catch { /* Deleted during the read: return this snapshot without caching. */ }
@@ -48,6 +48,15 @@ export declare class FeedWatchState {
48
48
  constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
49
49
  emit(event: FeedWatchPayload): FeedWatchEnvelope;
50
50
  }
51
+ /** Reuse session ownership reconciliation for the combined operator stream. */
52
+ export declare class FeedSessionProjection {
53
+ private readonly state;
54
+ private readonly sessions;
55
+ private readonly observations;
56
+ private projectedAttention;
57
+ constructor(state?: FeedWatchState);
58
+ apply(event: FeedWatchEnvelope): FeedWatchEnvelope[];
59
+ }
51
60
  export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState, gh?: GhExec): Promise<FeedWatchEnvelope[]>;
52
61
  export interface WatchLocalFeedOptions {
53
62
  scope: string;
@@ -1,3 +1,4 @@
1
+ import { SessionProjection } from '../session/projection.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
@@ -21,6 +22,86 @@ export class FeedWatchState {
21
22
  return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
22
23
  }
23
24
  }
25
+ /** Reuse session ownership reconciliation for the combined operator stream. */
26
+ export class FeedSessionProjection {
27
+ state;
28
+ sessions = new SessionProjection();
29
+ observations = new Map();
30
+ projectedAttention = new Map();
31
+ constructor(state = new FeedWatchState()) {
32
+ this.state = state;
33
+ }
34
+ apply(event) {
35
+ const scope = normalizeHost(event.scope);
36
+ const base = { version: 1, streamId: event.streamId, sequence: event.sequence, capturedAt: 'capturedAt' in event ? event.capturedAt : Date.now(), scope };
37
+ let sessionEvent;
38
+ if (event.type === 'reset') {
39
+ sessionEvent = { ...base, type: 'reset', rows: event.agents };
40
+ const attention = new Map();
41
+ for (const row of event.agents) {
42
+ const item = event.attention.find(item => item.sessionId === row.sessionId);
43
+ if (item && !row.previous)
44
+ attention.set(row.rowKey, item);
45
+ }
46
+ this.observations.set(scope, attention);
47
+ }
48
+ else if (event.type === 'agent.upsert') {
49
+ sessionEvent = { ...base, type: 'upsert', rowKey: event.rowKey, row: event.agent };
50
+ }
51
+ else if (event.type === 'agent.remove') {
52
+ sessionEvent = { ...base, type: 'remove', rowKey: event.rowKey };
53
+ this.observations.get(scope)?.delete(event.rowKey);
54
+ }
55
+ else if (event.type === 'attention.upsert') {
56
+ if (!this.observations.has(scope))
57
+ this.observations.set(scope, new Map());
58
+ this.observations.get(scope).set(event.rowKey, event.attention);
59
+ }
60
+ else if (event.type === 'attention.remove') {
61
+ this.observations.get(scope)?.delete(event.rowKey);
62
+ }
63
+ else {
64
+ const { v: _v, streamId: _streamId, sequence: _sequence, ...payload } = event;
65
+ return [this.state.emit(payload)];
66
+ }
67
+ const projected = sessionEvent ? this.sessions.apply(sessionEvent) : [];
68
+ const next = new Map();
69
+ for (const [observer, items] of this.observations)
70
+ for (const [rowKey, item] of items) {
71
+ const row = this.sessions.rowForObservation(observer, rowKey);
72
+ if (row && !row.previous && row.sourceDevice === observer)
73
+ next.set(item.key, { scope: observer, item });
74
+ }
75
+ const result = [];
76
+ const resetScopes = new Set();
77
+ for (const rowEvent of projected) {
78
+ if (rowEvent.type === 'reset') {
79
+ resetScopes.add(rowEvent.scope);
80
+ result.push(this.state.emit({ type: 'reset', scope: rowEvent.scope, capturedAt: rowEvent.capturedAt, agents: rowEvent.rows,
81
+ attention: [...next.values()].filter(value => value.scope === rowEvent.scope).map(value => value.item) }));
82
+ }
83
+ else if (rowEvent.type === 'upsert') {
84
+ result.push(this.state.emit({ type: 'agent.upsert', scope: rowEvent.scope, rowKey: rowEvent.rowKey, agent: rowEvent.row }));
85
+ }
86
+ else if (rowEvent.type === 'remove') {
87
+ result.push(this.state.emit({ type: 'agent.remove', scope: rowEvent.scope, rowKey: rowEvent.rowKey }));
88
+ }
89
+ }
90
+ // Attention has its own stable generation key, also used by feed answer.
91
+ // Never substitute the projected agent row key: reset consumers key by item.key.
92
+ for (const [key, previous] of this.projectedAttention) {
93
+ if (!next.has(key) && !resetScopes.has(previous.scope))
94
+ result.push(this.state.emit({ type: 'attention.remove', scope: previous.scope, rowKey: key }));
95
+ }
96
+ for (const [key, value] of next) {
97
+ if (!resetScopes.has(value.scope) && JSON.stringify(this.projectedAttention.get(key)) !== JSON.stringify(value)) {
98
+ result.push(this.state.emit({ type: 'attention.upsert', scope: value.scope, rowKey: key, attention: value.item }));
99
+ }
100
+ }
101
+ this.projectedAttention = next;
102
+ return result;
103
+ }
104
+ }
24
105
  /**
25
106
  * Live rows only: durable Previous rows share the operator stream for Sessions
26
107
  * history, but they are not live work and must never synthesize Needs-you
@@ -92,13 +173,19 @@ function watchAttentionStores(onChange) {
92
173
  const watchers = [];
93
174
  for (const dir of [feedDir, path.join(feedDir, 'resolutions')]) {
94
175
  try {
176
+ // `resolutions` is created lazily by the first `recordResolution` call, so
177
+ // a fresh feed dir has no directory to watch yet. Create it up front —
178
+ // otherwise fs.watch throws ENOENT here, is swallowed, and never retried,
179
+ // silently downgrading every resolution to the 45 s PR-status fallback
180
+ // instead of the event-driven reconcile this function exists to provide.
181
+ fs.mkdirSync(dir, { recursive: true });
95
182
  const watcher = fs.watch(dir, () => onChange());
96
183
  // A directory that disappears must not take the watcher process down; the
97
184
  // PR-status cadence below still reconciles on its own timer.
98
185
  watcher.on('error', () => watcher.close());
99
186
  watchers.push(watcher);
100
187
  }
101
- catch { /* the dir appears with the first block; the timed pass covers it */ }
188
+ catch { /* best-effort: the timed pass still reconciles on its own cadence */ }
102
189
  }
103
190
  return () => { for (const watcher of watchers)
104
191
  watcher.close(); };
@@ -217,9 +304,10 @@ function remoteFeedWatchCommand(os) {
217
304
  }
218
305
  export async function watchFleetFeed(options) {
219
306
  const coordinator = new FeedWatchState();
307
+ const projection = new FeedSessionProjection(coordinator);
220
308
  const forward = (event) => {
221
- const { v: _v, streamId: peerStreamId, sequence: peerSequence, ...payload } = event;
222
- options.emit(coordinator.emit({ ...payload, peerStreamId, peerSequence }));
309
+ for (const projected of projection.apply(event))
310
+ options.emit(projected);
223
311
  };
224
312
  const local = watchLocalFeed({ scope: machineId(), signal: options.signal, emit: forward });
225
313
  let devices;
@@ -21,7 +21,7 @@ import { resolveProjectNameForCwd, listProjectDefs } from './projects.js';
21
21
  import { getHistoryDir } from './state.js';
22
22
  import { machineId } from './machine-id.js';
23
23
  import { isValidMailboxId } from './mailbox.js';
24
- import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
24
+ import { listPidSessionEntries, readLivePidSessionEntry as readPidSessionEntry, } from './session/pid-registry.js';
25
25
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
26
26
  export const STATUS_POST_MAX_CHARS = 500;
27
27
  /** Title is a phone subject line - about four or five words, not a paragraph. */
@@ -1,13 +1,3 @@
1
- /**
2
- * Dispatch a headless `agents …` command onto a host over SSH.
3
- *
4
- * The command is launched detached (`nohup … &`) writing combined output to a
5
- * remote log and its exit code to a sibling `.exit` file, so progress survives a
6
- * dropped connection (followed via offset-tail in progress.ts). This is the
7
- * offload win: the process/thread/file fan-out happens on the host, not the
8
- * laptop. `agents run` uses it; `agents teams start --watch --device` reuses the
9
- * same core so a remote team supervisor keeps running after you disconnect.
10
- */
11
1
  import type { Host } from './types.js';
12
2
  import { type HostTask } from './tasks.js';
13
3
  import { type HostCredentials } from './credentials.js';
@@ -1,3 +1,4 @@
1
+ import { launchIdentityEnv } from '../launch-identity.js';
1
2
  /**
2
3
  * Dispatch a headless `agents …` command onto a host over SSH.
3
4
  *
@@ -63,7 +64,7 @@ const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
63
64
  * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
64
65
  */
65
66
  export function withActorEnv(env) {
66
- return { ...actorEnv(resolveActor()), ...terminalIdEnv(), ...(env ?? {}) };
67
+ return { ...actorEnv(resolveActor()), ...launchIdentityEnv(), ...(env ?? {}) };
67
68
  }
68
69
  /**
69
70
  * The shell-export prelude prepended to EVERY remote `agents run` dispatch —
@@ -88,24 +89,6 @@ export function remoteRunShellPrelude(agent, extra = {}) {
88
89
  const exports = posixEnvExports(withActorEnv({ ...guard, ...extra }));
89
90
  return exports ? `${exports}; ` : '';
90
91
  }
91
- /**
92
- * Forward the launching editor tab's `AGENT_TERMINAL_ID` across the SSH hop.
93
- *
94
- * Factory stamps it on every terminal it spawns, and the remote `agents run`
95
- * records it in its pid registry (`writePidSessionEntry`) — which is what lets a
96
- * tab ask the device "which session is MY terminal running?" instead of guessing
97
- * from local state. Without the forward the remote registry has no terminal id,
98
- * that question is unanswerable, and the tab is stuck with its spawn-time id even
99
- * after the agent has moved to a different session (a `/clear`, or an exit and
100
- * rerun in the same tab).
101
- *
102
- * Same shape as the actor provenance above: absent when the launch did not come
103
- * from a tracked terminal, never fabricated.
104
- */
105
- function terminalIdEnv() {
106
- const terminalId = process.env.AGENT_TERMINAL_ID?.trim();
107
- return terminalId ? { AGENT_TERMINAL_ID: terminalId } : {};
108
- }
109
92
  /**
110
93
  * Launch a detached login-shell command in its own Unix session/process group.
111
94
  *
@@ -9,37 +9,15 @@
9
9
  *
10
10
  * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
11
  * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
- * SessionStart hook records the agent's REAL session id under that exact key in
13
- * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
- * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
- * the same launch-id join `agents sessions --active` uses locally
16
- * (session/hook-sessions.ts), just across the SSH hop.
12
+ * SessionStart hook records the agent's REAL session id. After the stream
13
+ * returns we ask the owning CLI to join that hook with the durable launch
14
+ * registry, using the same index as local focus/session projection. This also
15
+ * supports the deployed legacy hook containing only session_id and pid.
17
16
  *
18
17
  * This gives the interactive path a real per-agent id to register in the local
19
18
  * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
19
  * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
20
  * id up front and never reaches here).
22
21
  */
23
- /**
24
- * Pick the real session id for `launchId` from the remote hook records.
25
- *
26
- * `recordsJson` is the newline-delimited JSON the remote read emits — one record
27
- * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
28
- * `launch_id` matches and return its `session_id`; when several match (pid reuse,
29
- * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
30
- * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
31
- * unit-testable from fixtures with no SSH. Returns undefined when no record
32
- * carries the launch id (the hook hasn't landed yet, or a hookless harness).
33
- */
34
- export declare function pickRemoteSessionId(recordsJson: string, launchId: string): string | undefined;
35
- /**
36
- * Read the remote hook-session dir over ssh and resolve the real session id the
37
- * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
38
- * failure, a missing dir, or an absent record yields undefined — the caller then
39
- * keeps the un-mapped run rather than a fabricated id.
40
- *
41
- * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
42
- * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
43
- * globless dir keeps the command quiet when the dir is empty or absent.
44
- */
22
+ /** Resolve on the execution owner through the canonical read-only CLI projection. */
45
23
  export declare function resolveRemoteSessionId(target: string, launchId: string, timeoutMs?: number): string | undefined;
@@ -9,76 +9,33 @@
9
9
  *
10
10
  * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
11
  * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
- * SessionStart hook records the agent's REAL session id under that exact key in
13
- * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
- * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
- * the same launch-id join `agents sessions --active` uses locally
16
- * (session/hook-sessions.ts), just across the SSH hop.
12
+ * SessionStart hook records the agent's REAL session id. After the stream
13
+ * returns we ask the owning CLI to join that hook with the durable launch
14
+ * registry, using the same index as local focus/session projection. This also
15
+ * supports the deployed legacy hook containing only session_id and pid.
17
16
  *
18
17
  * This gives the interactive path a real per-agent id to register in the local
19
18
  * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
19
  * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
20
  * id up front and never reaches here).
22
21
  */
23
- import { sshExec } from '../ssh-exec.js';
24
- /**
25
- * Pick the real session id for `launchId` from the remote hook records.
26
- *
27
- * `recordsJson` is the newline-delimited JSON the remote read emits — one record
28
- * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
29
- * `launch_id` matches and return its `session_id`; when several match (pid reuse,
30
- * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
31
- * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
32
- * unit-testable from fixtures with no SSH. Returns undefined when no record
33
- * carries the launch id (the hook hasn't landed yet, or a hookless harness).
34
- */
35
- export function pickRemoteSessionId(recordsJson, launchId) {
36
- if (!launchId)
37
- return undefined;
38
- let best;
39
- for (const line of recordsJson.split('\n')) {
40
- const trimmed = line.trim();
41
- if (!trimmed)
42
- continue;
43
- let rec;
44
- try {
45
- rec = JSON.parse(trimmed);
46
- }
47
- catch {
48
- continue; // a partial/garbled line — skip, never throw
49
- }
50
- if (rec.launch_id !== launchId)
51
- continue;
52
- if (typeof rec.session_id !== 'string' || !rec.session_id)
53
- continue;
54
- const ts = typeof rec.ts === 'number' ? rec.ts : 0;
55
- // Strict `>` so the FIRST record wins a tie — matches keepNewest (hook-sessions.ts).
56
- if (!best || ts > best.ts)
57
- best = { sid: rec.session_id, ts };
58
- }
59
- return best?.sid;
60
- }
61
- /**
62
- * Read the remote hook-session dir over ssh and resolve the real session id the
63
- * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
64
- * failure, a missing dir, or an absent record yields undefined — the caller then
65
- * keeps the un-mapped run rather than a fabricated id.
66
- *
67
- * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
68
- * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
69
- * globless dir keeps the command quiet when the dir is empty or absent.
70
- */
22
+ import { sshExec, shellQuote } from '../ssh-exec.js';
23
+ /** Resolve on the execution owner through the canonical read-only CLI projection. */
71
24
  export function resolveRemoteSessionId(target, launchId, timeoutMs = 6000) {
72
25
  if (!launchId)
73
26
  return undefined;
74
- // `cat` every record onto its own line. `head -c` caps a runaway file; the
75
- // trailing `true` keeps the pipeline's exit 0 even when the glob matches nothing.
76
- const cmd = 'cat "$HOME"/.agents/.cache/terminals/sessions/*.json 2>/dev/null | head -c 1048576 || true';
27
+ // Let the owning CLI join its deployed hook and launch registry. A remote
28
+ // caller must not carry a second implementation of session identity rules.
29
+ const cmd = `agents sessions --resolve-launch-id ${shellQuote(launchId)} --json --local`;
77
30
  const res = sshExec(target, cmd, { timeoutMs, multiplex: true });
78
- if (res.code !== 0 && !res.stdout)
31
+ if (res.code !== 0)
79
32
  return undefined;
80
- // Each record is a single-line JSON object; `cat` may run them together, so
81
- // normalise `}{` boundaries onto separate lines before scanning.
82
- const normalised = res.stdout.replace(/\}\s*\{/g, '}\n{');
83
- return pickRemoteSessionId(normalised, launchId);
33
+ try {
34
+ const record = JSON.parse(res.stdout);
35
+ return record.launchId === launchId && typeof record.sessionId === 'string' && record.sessionId
36
+ ? record.sessionId : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
84
41
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Editor identity belongs to the first launch attached to that terminal. The
3
+ * harness inherits it for its own tools, but another `agents run` is a new
4
+ * launch, not another owner of the parent's editor tab. AGENTS_RUNTIME is set
5
+ * by buildExecEnv, whereas an editor's initial launch sets AGENT_TERMINAL_ID
6
+ * (and may already set AGENT_SESSION_ID) without that runtime marker.
7
+ */
8
+ export declare function launchIdentityEnv(env?: NodeJS.ProcessEnv): Record<string, string>;
9
+ /** Identity absent from a new launch must not leak back in from a tmux server. */
10
+ export declare const LAUNCH_IDENTITY_KEYS: readonly ["AGENT_TERMINAL_ID", "AGENT_SESSION_ID", "AGENTS_SESSION_ID", "AGENTS_MAILBOX_DIR", "AGENT_LAUNCH_ID", "AGENTS_PARENT_SESSION_ID", "AGENTS_PARENT_LAUNCH_ID", "AGENTS_ORIGIN_TERMINAL_ID"];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Editor identity belongs to the first launch attached to that terminal. The
3
+ * harness inherits it for its own tools, but another `agents run` is a new
4
+ * launch, not another owner of the parent's editor tab. AGENTS_RUNTIME is set
5
+ * by buildExecEnv, whereas an editor's initial launch sets AGENT_TERMINAL_ID
6
+ * (and may already set AGENT_SESSION_ID) without that runtime marker.
7
+ */
8
+ export function launchIdentityEnv(env = process.env) {
9
+ const result = {};
10
+ const terminal = env.AGENT_TERMINAL_ID?.trim();
11
+ const nested = env.AGENTS_RUNTIME === 'terminal' || env.AGENTS_RUNTIME === 'headless' || env.AGENTS_RUNTIME === 'teams';
12
+ if (terminal && !nested)
13
+ result.AGENT_TERMINAL_ID = terminal;
14
+ const originTerminal = env.AGENTS_ORIGIN_TERMINAL_ID?.trim() || terminal;
15
+ if (originTerminal)
16
+ result.AGENTS_ORIGIN_TERMINAL_ID = originTerminal;
17
+ const parentLaunch = nested ? env.AGENT_LAUNCH_ID : env.AGENTS_PARENT_LAUNCH_ID;
18
+ const parentSession = nested
19
+ ? env.AGENTS_SESSION_ID || env.AGENT_SESSION_ID
20
+ : env.AGENTS_PARENT_SESSION_ID;
21
+ if (parentLaunch)
22
+ result.AGENTS_PARENT_LAUNCH_ID = parentLaunch;
23
+ if (parentSession)
24
+ result.AGENTS_PARENT_SESSION_ID = parentSession;
25
+ return result;
26
+ }
27
+ /** Identity absent from a new launch must not leak back in from a tmux server. */
28
+ export const LAUNCH_IDENTITY_KEYS = [
29
+ 'AGENT_TERMINAL_ID', 'AGENT_SESSION_ID', 'AGENTS_SESSION_ID', 'AGENTS_MAILBOX_DIR',
30
+ 'AGENT_LAUNCH_ID', 'AGENTS_PARENT_SESSION_ID', 'AGENTS_PARENT_LAUNCH_ID', 'AGENTS_ORIGIN_TERMINAL_ID',
31
+ ];
@@ -1,3 +1,4 @@
1
+ import { writerProcessView } from './process-view.js';
1
2
  /**
2
3
  * Active-session detection across every context an agent can run in:
3
4
  *
@@ -24,7 +25,7 @@ import { promisify } from 'util';
24
25
  import { listActiveTasks } from '../cloud/store.js';
25
26
  import { AgentManager } from '../teams/agents.js';
26
27
  import { getTerminalsDir } from '../state.js';
27
- import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry, sessionIdFromLivePid, isSessionIdShape, } from './pid-registry.js';
28
+ import { readLivePidSessionEntry as readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry, sessionIdFromLivePid, isSessionIdShape, } from './pid-registry.js';
28
29
  import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
29
30
  import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
30
31
  import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
@@ -1696,8 +1697,9 @@ export async function listTmuxAgentSessions() {
1696
1697
  // split) resolves to its own agent. Newest launch wins a pane (pid reuse), and
1697
1698
  // only live pids count — a dead agent's stale entry can't light up its old pane.
1698
1699
  const liveByPane = new Map();
1699
- for (const e of listPidSessionEntries()) {
1700
- if (!e.tmuxPane || !isPidAlive(e.pid, e.startedAtMs))
1700
+ for (const recorded of listPidSessionEntries()) {
1701
+ const e = readPidSessionEntry(recorded.pid);
1702
+ if (!e?.tmuxPane)
1701
1703
  continue;
1702
1704
  const prev = liveByPane.get(e.tmuxPane);
1703
1705
  if (!prev || e.startedAtMs > prev.startedAtMs)
@@ -1824,6 +1826,10 @@ export async function listTmuxAgentSessions() {
1824
1826
  * terminal/headless row for the same session id.
1825
1827
  */
1826
1828
  export async function getActiveSessions(opts = {}) {
1829
+ if (!writerProcessView()) {
1830
+ const { loadLocalActiveSessions } = await import('./session-cache.js');
1831
+ return (await loadLocalActiveSessions()).sessions;
1832
+ }
1827
1833
  const [tmuxAgents, teams, terminals, cloud] = await Promise.all([
1828
1834
  listTmuxAgentSessions().catch(() => []),
1829
1835
  listTeamsActive({ localOnly: opts.localOnly }).catch(() => []),
@@ -30,8 +30,9 @@ export declare const SCHEMA_VERSION = 50;
30
30
  * genuine first-user-turn extractor was added.
31
31
  * v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
32
32
  * chat_history.jsonl prefix read filled firstUserMessage.
33
+ * v5 (PHNX-3939) restores first-meta Codex fork ownership, including cold files.
33
34
  */
34
- export declare const CONTENT_INDEX_VERSION = 4;
35
+ export declare const CONTENT_INDEX_VERSION = 5;
35
36
  /**
36
37
  * Bumping this invalidates every cached facet row without touching the schema
37
38
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
@@ -16,7 +16,7 @@ import { query as queryEvents, queryToolUsageForSessions } from '../feed/events.
16
16
  import { machineForSessionFile } from '../origin-machine.js';
17
17
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
18
18
  import { scanEventToolCalls } from './tool-calls.js';
19
- import { persistToolCalls, planEventToolResume, toolEvidenceSourcePath } from './tool-store.js';
19
+ import { persistToolCalls, planEventToolResume, purgeToolCalls, toolEvidenceSourcePath } from './tool-store.js';
20
20
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
21
21
  import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTracksBackgroundShells, isSubAgentTool, } from './highlights.js';
22
22
  import { resolveResource } from '../resources.js';
@@ -45,8 +45,9 @@ export const SCHEMA_VERSION = 50;
45
45
  * genuine first-user-turn extractor was added.
46
46
  * v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
47
47
  * chat_history.jsonl prefix read filled firstUserMessage.
48
+ * v5 (PHNX-3939) restores first-meta Codex fork ownership, including cold files.
48
49
  */
49
- export const CONTENT_INDEX_VERSION = 4;
50
+ export const CONTENT_INDEX_VERSION = 5;
50
51
  /**
51
52
  * Bump to force `agents sessions backfill resources` to re-derive every
52
53
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -1452,8 +1453,17 @@ export function getDB() {
1452
1453
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
1453
1454
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
1454
1455
  const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
1455
- if (!sessionColumns.some(column => column.name === 'account_id'))
1456
- db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
1456
+ if (['account_id', 'phoenix_id'].some(name => !sessionColumns.some(column => column.name === name))) {
1457
+ // Partial upgrades can stamp the current version before every column exists.
1458
+ // Recheck under the writer lock so concurrent openers cannot add it twice.
1459
+ db.transaction(() => {
1460
+ const columns = db.prepare('PRAGMA table_info(sessions)').all();
1461
+ if (!columns.some(column => column.name === 'account_id'))
1462
+ db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
1463
+ if (!columns.some(column => column.name === 'phoenix_id'))
1464
+ db.exec('ALTER TABLE sessions ADD COLUMN phoenix_id TEXT');
1465
+ })();
1466
+ }
1457
1467
  // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
1458
1468
  // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
1459
1469
  // migrated by a build that predates the "clear the stale email on a dark row" fix
@@ -2333,6 +2343,30 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2333
2343
  });
2334
2344
  txn();
2335
2345
  }
2346
+ /** A verified Codex rollout has one native owner. An older last-meta scan may
2347
+ * have indexed its child bytes under the parent id. Reconcile only paths that
2348
+ * were successfully written, after the whole batch, so a real parent rollout
2349
+ * repaired in the same transaction keeps its own row regardless of scan order. */
2350
+ function reconcileCodexFileOwners(db, metas) {
2351
+ const owners = new Map(metas.filter(meta => meta.agent === 'codex' && meta.filePath).map(meta => [meta.filePath, meta.id]));
2352
+ const paths = [...owners.keys()];
2353
+ for (let i = 0; i < paths.length; i += 500) {
2354
+ const chunk = paths.slice(i, i + 500);
2355
+ const rows = db.prepare(`SELECT id, file_path FROM sessions WHERE agent = 'codex' AND file_path IN (${chunk.map(() => '?').join(',')})`)
2356
+ .all(...chunk);
2357
+ for (const row of rows) {
2358
+ if (owners.get(row.file_path) === row.id)
2359
+ continue;
2360
+ db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
2361
+ for (const table of ['session_text', 'session_preview_cache', 'session_summaries', 'session_insights', 'session_topics', 'session_phenotypes', 'session_resource_usage', 'resource_scan_ledger']) {
2362
+ db.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(row.id);
2363
+ }
2364
+ // The tool ledger has a unique file path too; release the wrong binding
2365
+ // before the child evidence is persisted below. Transcript files stay put.
2366
+ purgeToolCalls(db, row.id);
2367
+ }
2368
+ }
2369
+ }
2336
2370
  /** Batch-upsert sessions with their FTS5 content and scan stamps in a single transaction. */
2337
2371
  export function upsertSessionsBatch(entries) {
2338
2372
  if (entries.length === 0)
@@ -2582,6 +2616,7 @@ export function upsertSessionsBatch(entries) {
2582
2616
  }
2583
2617
  }
2584
2618
  }
2619
+ reconcileCodexFileOwners(db, writtenEntries.map(entry => entry.meta));
2585
2620
  });
2586
2621
  txn(enrichedEntries);
2587
2622
  // Tool evidence shares the transcript parse above but owns an independent