@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.
- package/CHANGELOG.md +12 -0
- package/dist/commands/exec.d.ts +106 -1
- package/dist/commands/exec.js +10 -10
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +12 -0
- package/dist/lib/daemon/daemon.d.ts +0 -8
- package/dist/lib/daemon/daemon.js +41 -22
- package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
- package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-start.js +43 -0
- package/dist/lib/event-provenance.d.ts +2 -0
- package/dist/lib/event-provenance.js +4 -0
- package/dist/lib/exec.js +27 -7
- package/dist/lib/feed/activity-stream.js +9 -0
- package/dist/lib/feed/activity.d.ts +8 -0
- package/dist/lib/feed/activity.js +61 -18
- package/dist/lib/feed/watch.d.ts +9 -0
- package/dist/lib/feed/watch.js +91 -3
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +0 -10
- package/dist/lib/hosts/dispatch.js +2 -19
- package/dist/lib/hosts/remote-session-id.d.ts +5 -27
- package/dist/lib/hosts/remote-session-id.js +18 -61
- package/dist/lib/launch-identity.d.ts +10 -0
- package/dist/lib/launch-identity.js +31 -0
- package/dist/lib/session/active.js +9 -3
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +39 -4
- package/dist/lib/session/discover.js +7 -2
- package/dist/lib/session/hook-sessions.d.ts +10 -0
- package/dist/lib/session/hook-sessions.js +139 -6
- package/dist/lib/session/pid-registry.d.ts +18 -5
- package/dist/lib/session/pid-registry.js +164 -17
- package/dist/lib/session/process-view.d.ts +25 -0
- package/dist/lib/session/process-view.js +176 -0
- package/dist/lib/session/projection.d.ts +13 -0
- package/dist/lib/session/projection.js +101 -0
- package/dist/lib/session/remote/watch.d.ts +8 -0
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/session-cache.js +15 -0
- package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
- package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-namespace.js +56 -0
- package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-private.js +12 -0
- 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
|
|
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
|
-
|
|
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. */ }
|
package/dist/lib/feed/watch.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/feed/watch.js
CHANGED
|
@@ -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
|
|
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
|
|
222
|
-
|
|
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;
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -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()), ...
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const cmd =
|
|
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
|
|
31
|
+
if (res.code !== 0)
|
|
79
32
|
return undefined;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
1700
|
-
|
|
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(() => []),
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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 =
|
|
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)
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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 ===
|
|
1456
|
-
|
|
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
|