@phnx-labs/agents-cli 1.22.82 → 1.22.84
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 +47 -0
- package/dist/commands/accounts.js +34 -6
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/commands/view.js +3 -6
- package/dist/lib/account-registry.d.ts +40 -3
- package/dist/lib/account-registry.js +89 -18
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/claude-statusline.d.ts +13 -5
- package/dist/lib/claude-statusline.js +19 -6
- package/dist/lib/daemon-ticks.js +23 -2
- package/dist/lib/devices/fleet-inventory.js +3 -4
- package/dist/lib/devices/harness-inventory.js +4 -5
- package/dist/lib/feed/activity-stream.d.ts +60 -0
- package/dist/lib/feed/activity-stream.js +271 -0
- package/dist/lib/feed/activity.d.ts +7 -0
- package/dist/lib/feed/activity.js +103 -13
- package/dist/lib/feed/watch.d.ts +9 -3
- package/dist/lib/feed/watch.js +72 -36
- package/dist/lib/fleet-shared-state.d.ts +6 -0
- package/dist/lib/session/active.d.ts +38 -4
- package/dist/lib/session/active.js +36 -4
- package/dist/lib/session/bash-command.js +10 -0
- package/dist/lib/session/db.d.ts +47 -2
- package/dist/lib/session/db.js +131 -4
- package/dist/lib/session/mirror.d.ts +5 -0
- package/dist/lib/session/mirror.js +166 -0
- package/dist/lib/session/parse.d.ts +49 -0
- package/dist/lib/session/parse.js +324 -30
- package/dist/lib/session/prompt.d.ts +93 -2
- package/dist/lib/session/prompt.js +266 -15
- package/dist/lib/session/remote/peer-stream.d.ts +47 -0
- package/dist/lib/session/remote/peer-stream.js +142 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +41 -42
- package/dist/lib/session/session-cache.d.ts +9 -0
- package/dist/lib/session/session-cache.js +40 -2
- package/dist/lib/session/timeline-pass.d.ts +129 -0
- package/dist/lib/session/timeline-pass.js +323 -0
- package/dist/lib/session/timeline.d.ts +182 -0
- package/dist/lib/session/timeline.js +636 -0
- package/dist/lib/session/types.d.ts +155 -1
- package/dist/lib/summarizer/pass.d.ts +2 -0
- package/dist/lib/summarizer/pass.js +14 -1
- package/dist/lib/summarizer/summarize.d.ts +7 -0
- package/dist/lib/summarizer/summarize.js +3 -0
- package/package.json +1 -1
|
@@ -119,7 +119,11 @@ export function appendActivityEvent(event, root) {
|
|
|
119
119
|
};
|
|
120
120
|
fs.appendFileSync(activityPath(dir, event.sessionId), `${JSON.stringify(record)}\n`, { mode: 0o644 });
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Parse one activity log line. Tolerant by design: a blank, corrupt, or
|
|
124
|
+
* half-written line is skipped rather than failing the whole read.
|
|
125
|
+
*/
|
|
126
|
+
export function parseActivityLine(line) {
|
|
123
127
|
const trimmed = line.trim();
|
|
124
128
|
if (!trimmed)
|
|
125
129
|
return undefined;
|
|
@@ -160,6 +164,8 @@ function parseLine(line) {
|
|
|
160
164
|
return undefined; // skip corrupt / partial lines (fail-open reader)
|
|
161
165
|
}
|
|
162
166
|
}
|
|
167
|
+
/** Per-session byte budget for a bounded tail read of an activity log. */
|
|
168
|
+
export const ACTIVITY_TAIL_BYTES = 256 * 1024;
|
|
163
169
|
/** Read the tail of a file as UTF-8, bounded to the last `maxBytes`. */
|
|
164
170
|
function readTail(file, maxBytes) {
|
|
165
171
|
let fd;
|
|
@@ -181,24 +187,105 @@ function readTail(file, maxBytes) {
|
|
|
181
187
|
return text;
|
|
182
188
|
}
|
|
183
189
|
catch {
|
|
184
|
-
return
|
|
190
|
+
return undefined;
|
|
185
191
|
}
|
|
186
192
|
finally {
|
|
187
193
|
if (fd !== undefined)
|
|
188
194
|
fs.closeSync(fd);
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
// Process-local tail cache: bounded independently of the number of historical
|
|
198
|
+
// sessions or directories a long-lived consumer visits. Never expose cached
|
|
199
|
+
// objects: callers enrich and mutate returned events (including nested values).
|
|
200
|
+
const ACTIVITY_CACHE_BYTES = 32 * 1024 * 1024;
|
|
201
|
+
const ACTIVITY_CACHE_FILES = 1024;
|
|
202
|
+
const activityTails = new Map();
|
|
203
|
+
let activityTailBytes = 0;
|
|
204
|
+
function forgetActivityTail(key) {
|
|
205
|
+
const old = activityTails.get(key);
|
|
206
|
+
if (old)
|
|
207
|
+
activityTailBytes -= old.weight;
|
|
208
|
+
activityTails.delete(key);
|
|
209
|
+
}
|
|
210
|
+
function activityStamp(file) {
|
|
211
|
+
// ctime catches same-size rewrites even when a producer restores mtime;
|
|
212
|
+
// device/inode catches atomic replacement. Nanoseconds avoid rounding away
|
|
213
|
+
// changes made between consecutive polls on high-resolution filesystems.
|
|
214
|
+
const st = fs.statSync(file, { bigint: true });
|
|
215
|
+
return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
|
|
216
|
+
}
|
|
217
|
+
function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
|
|
218
|
+
const key = `${path.resolve(file)}\0${maxBytes}`;
|
|
219
|
+
let stamp;
|
|
220
|
+
try {
|
|
221
|
+
stamp = activityStamp(file);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
forgetActivityTail(key);
|
|
225
|
+
return { stamp: '', events: [], newestMs: -Infinity, weight: 0 };
|
|
226
|
+
}
|
|
227
|
+
const cached = activityTails.get(key);
|
|
228
|
+
if (cached?.stamp === stamp && (cached.events || cached.newestMs < sinceMs)) {
|
|
229
|
+
activityTails.delete(key);
|
|
230
|
+
activityTails.set(key, cached);
|
|
231
|
+
return cached;
|
|
232
|
+
}
|
|
233
|
+
forgetActivityTail(key);
|
|
234
|
+
const text = readTail(file, maxBytes);
|
|
235
|
+
// Transient I/O failure is not an empty file snapshot; retry next read.
|
|
236
|
+
if (text === undefined)
|
|
237
|
+
return { stamp, events: [], newestMs: -Infinity, weight: 0 };
|
|
238
|
+
const events = [];
|
|
239
|
+
let newestMs = -Infinity;
|
|
196
240
|
for (const line of text.split('\n')) {
|
|
197
|
-
const
|
|
198
|
-
if (
|
|
199
|
-
|
|
241
|
+
const event = parseActivityLine(line);
|
|
242
|
+
if (!event)
|
|
243
|
+
continue;
|
|
244
|
+
events.push(event);
|
|
245
|
+
try {
|
|
246
|
+
const ms = Date.parse(event.ts);
|
|
247
|
+
if (Number.isFinite(ms))
|
|
248
|
+
newestMs = Math.max(newestMs, ms);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// The tolerant parser historically accepts truthy non-string values.
|
|
252
|
+
// Do not introduce a conversion error before the caller's event filters
|
|
253
|
+
// (or into session reads, which never converted timestamps at all).
|
|
254
|
+
newestMs = Infinity;
|
|
255
|
+
}
|
|
200
256
|
}
|
|
201
|
-
|
|
257
|
+
// Bound retained source-derived strings, objects, and entry overhead. Large
|
|
258
|
+
// 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;
|
|
260
|
+
const result = { stamp, events, newestMs, weight };
|
|
261
|
+
try {
|
|
262
|
+
// A writer racing the read must not pin a mixed snapshot as unchanged.
|
|
263
|
+
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;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch { /* Deleted during the read: return this snapshot without caching. */ }
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
/** Read all events for one session (bounded tail). */
|
|
286
|
+
export function readSessionActivity(sessionId, root, maxBytes = ACTIVITY_TAIL_BYTES) {
|
|
287
|
+
const dir = root ?? getActivityDir();
|
|
288
|
+
return structuredClone(readActivityTail(activityPath(dir, sessionId), maxBytes).events ?? []);
|
|
202
289
|
}
|
|
203
290
|
/** List session ids that have an activity log. */
|
|
204
291
|
export function listActivitySessions(root) {
|
|
@@ -220,7 +307,10 @@ export function readRecentActivity(opts = {}) {
|
|
|
220
307
|
const wanted = opts.events && opts.events.length > 0 ? new Set(opts.events) : null;
|
|
221
308
|
const all = [];
|
|
222
309
|
for (const sessionId of listActivitySessions(dir)) {
|
|
223
|
-
|
|
310
|
+
const tail = readActivityTail(activityPath(dir, sessionId), opts.maxBytesPerSession ?? ACTIVITY_TAIL_BYTES, sinceMs);
|
|
311
|
+
if (tail.newestMs < sinceMs)
|
|
312
|
+
continue;
|
|
313
|
+
for (const ev of tail.events ?? []) {
|
|
224
314
|
if (wanted && !wanted.has(ev.event))
|
|
225
315
|
continue;
|
|
226
316
|
if (opts.tier && ev.tier !== opts.tier)
|
|
@@ -231,7 +321,7 @@ export function readRecentActivity(opts = {}) {
|
|
|
231
321
|
}
|
|
232
322
|
}
|
|
233
323
|
all.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
|
|
234
|
-
return typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all;
|
|
324
|
+
return structuredClone(typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all);
|
|
235
325
|
}
|
|
236
326
|
/**
|
|
237
327
|
* Split a chronological event list into individual milestones plus a
|
package/dist/lib/feed/watch.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus } from '../session/watch.js';
|
|
1
|
+
import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus, type WatchLocalOptions } from '../session/watch.js';
|
|
2
2
|
import { type AttentionItem } from './attention.js';
|
|
3
3
|
import { type ActivityEvent } from './activity.js';
|
|
4
4
|
export declare const FEED_WATCH_VERSION: 1;
|
|
@@ -48,12 +48,18 @@ export declare class FeedWatchState {
|
|
|
48
48
|
emit(event: FeedWatchPayload): FeedWatchEnvelope;
|
|
49
49
|
}
|
|
50
50
|
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
|
|
51
|
-
export
|
|
51
|
+
export interface WatchLocalFeedOptions {
|
|
52
52
|
scope: string;
|
|
53
53
|
signal: AbortSignal;
|
|
54
54
|
emit: (event: FeedWatchEnvelope) => void;
|
|
55
|
+
/** Activity drain cadence. */
|
|
55
56
|
activityPollMs?: number;
|
|
56
|
-
|
|
57
|
+
/** Attention reconcile cadence when nothing has announced a change. */
|
|
58
|
+
reconcileMs?: number;
|
|
59
|
+
/** Session-watch inputs, forwarded verbatim to {@link watchLocalSessions}. */
|
|
60
|
+
sessions?: Pick<WatchLocalOptions, 'readCache' | 'readPrevious' | 'journalPath' | 'journalPollMs' | 'heartbeatMs'>;
|
|
61
|
+
}
|
|
62
|
+
export declare function watchLocalFeed(options: WatchLocalFeedOptions): Promise<void>;
|
|
57
63
|
export declare function watchFleetFeed(options: {
|
|
58
64
|
signal: AbortSignal;
|
|
59
65
|
emit: (event: FeedWatchEnvelope) => void;
|
package/dist/lib/feed/watch.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
4
|
import { loadDevices, isDialableDevice } from '../devices/registry.js';
|
|
5
|
-
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
6
5
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
7
|
-
import {
|
|
6
|
+
import { shellQuote } from '../ssh-exec.js';
|
|
8
7
|
import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
|
|
8
|
+
import { getFeedDir } from '../state.js';
|
|
9
|
+
import { streamFromPeer } from '../session/remote/peer-stream.js';
|
|
9
10
|
import { watchLocalSessions } from '../session/watch.js';
|
|
10
11
|
import { readBlock, readResolution, blockIdForSession } from './feed.js';
|
|
11
12
|
import { reconcileAttention } from './attention.js';
|
|
12
|
-
import {
|
|
13
|
-
import { readPullRequestStatus } from './pr-status.js';
|
|
13
|
+
import { ActivityStream } from './activity-stream.js';
|
|
14
|
+
import { PR_STATUS_TTL_MS, readPullRequestStatus } from './pr-status.js';
|
|
14
15
|
export const FEED_WATCH_VERSION = 1;
|
|
15
16
|
export class FeedWatchState {
|
|
16
17
|
streamId;
|
|
@@ -64,8 +65,37 @@ export async function projectSessionEnvelope(event, state) {
|
|
|
64
65
|
return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
|
|
65
66
|
return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Watch the feed dirs that can change attention without a session event: an
|
|
70
|
+
* `agents feed post --blocked` writes a block, `feed answer` writes a
|
|
71
|
+
* resolution. Both are external processes, so nothing on the session stream
|
|
72
|
+
* announces them — this is what lets the reconcile pass be event-driven instead
|
|
73
|
+
* of a poll over every row twice a second.
|
|
74
|
+
*/
|
|
75
|
+
function watchAttentionStores(onChange) {
|
|
76
|
+
const feedDir = getFeedDir();
|
|
77
|
+
const watchers = [];
|
|
78
|
+
for (const dir of [feedDir, path.join(feedDir, 'resolutions')]) {
|
|
79
|
+
try {
|
|
80
|
+
const watcher = fs.watch(dir, () => onChange());
|
|
81
|
+
// A directory that disappears must not take the watcher process down; the
|
|
82
|
+
// PR-status cadence below still reconciles on its own timer.
|
|
83
|
+
watcher.on('error', () => watcher.close());
|
|
84
|
+
watchers.push(watcher);
|
|
85
|
+
}
|
|
86
|
+
catch { /* the dir appears with the first block; the timed pass covers it */ }
|
|
87
|
+
}
|
|
88
|
+
return () => { for (const watcher of watchers)
|
|
89
|
+
watcher.close(); };
|
|
90
|
+
}
|
|
67
91
|
export async function watchLocalFeed(options) {
|
|
68
92
|
const state = new FeedWatchState();
|
|
93
|
+
// The stream's opening scan registers every log past its own bytes, so the
|
|
94
|
+
// cursor has to be taken *after* it: a record appended while the scan was
|
|
95
|
+
// walking the directory is unreadable from the byte cursor, and only a
|
|
96
|
+
// timestamp taken after the scan classifies it as history rather than
|
|
97
|
+
// dropping it from a window the caller believes was covered.
|
|
98
|
+
const activity = new ActivityStream();
|
|
69
99
|
let activityCursor = Date.now();
|
|
70
100
|
const agents = new Map();
|
|
71
101
|
const attention = new Map();
|
|
@@ -82,20 +112,34 @@ export async function watchLocalFeed(options) {
|
|
|
82
112
|
: state.emit({ type: 'attention.remove', scope: options.scope, rowKey }));
|
|
83
113
|
}
|
|
84
114
|
};
|
|
115
|
+
// Attention is reconciled when something can actually have changed it — a row
|
|
116
|
+
// moved, a block/resolution file was written — or when the PR-status TTL has
|
|
117
|
+
// expired and the cached verdicts are stale. Re-running it every 500 ms cost
|
|
118
|
+
// two file reads per row per tick and changed nothing.
|
|
119
|
+
const reconcileMs = options.reconcileMs ?? PR_STATUS_TTL_MS;
|
|
120
|
+
let attentionDirty = true;
|
|
121
|
+
let lastReconcileMs = 0;
|
|
122
|
+
const markAttentionDirty = () => { attentionDirty = true; };
|
|
123
|
+
const stopAttentionWatch = watchAttentionStores(markAttentionDirty);
|
|
85
124
|
const activityTimer = setInterval(() => {
|
|
86
125
|
pending = pending.then(async () => {
|
|
87
|
-
const
|
|
126
|
+
const nowMs = Date.now();
|
|
127
|
+
const events = activity.read(activityCursor + 1, nowMs).reverse();
|
|
88
128
|
for (const event of events) {
|
|
89
129
|
activityCursor = Math.max(activityCursor, Date.parse(event.ts));
|
|
90
130
|
options.emit(state.emit({ type: 'activity.append', scope: options.scope, event }));
|
|
91
131
|
}
|
|
132
|
+
if (!attentionDirty && nowMs - lastReconcileMs < reconcileMs)
|
|
133
|
+
return;
|
|
134
|
+
attentionDirty = false;
|
|
135
|
+
lastReconcileMs = nowMs;
|
|
92
136
|
await reconcileRows();
|
|
93
137
|
});
|
|
94
138
|
}, options.activityPollMs ?? 500);
|
|
95
|
-
const stopActivity = () => clearInterval(activityTimer);
|
|
139
|
+
const stopActivity = () => { clearInterval(activityTimer); stopAttentionWatch(); activity.close(); };
|
|
96
140
|
options.signal.addEventListener('abort', stopActivity, { once: true });
|
|
97
141
|
try {
|
|
98
|
-
await watchLocalSessions({ scope: options.scope, signal: options.signal, emit: (event) => {
|
|
142
|
+
await watchLocalSessions({ ...options.sessions, scope: options.scope, signal: options.signal, emit: (event) => {
|
|
99
143
|
if (event.type === 'reset') {
|
|
100
144
|
agents.clear();
|
|
101
145
|
for (const row of event.rows)
|
|
@@ -156,35 +200,27 @@ export async function watchFleetFeed(options) {
|
|
|
156
200
|
}
|
|
157
201
|
const self = machineId();
|
|
158
202
|
const peers = Object.values(devices).filter((device) => isDialableDevice(device) && normalizeHost(device.name) !== self && ['windows', 'linux', 'macos'].includes(device.platform));
|
|
159
|
-
const tasks = peers.map(
|
|
203
|
+
const tasks = peers.map((device) => {
|
|
160
204
|
const scope = normalizeHost(device.name);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const stop = () => child.kill('SIGTERM');
|
|
172
|
-
options.signal.addEventListener('abort', stop, { once: true });
|
|
173
|
-
const reader = createInterface({ input: child.stdout });
|
|
174
|
-
reader.on('line', (line) => { try {
|
|
175
|
-
const event = JSON.parse(line);
|
|
176
|
-
if (event.v === 1)
|
|
205
|
+
return streamFromPeer({
|
|
206
|
+
device,
|
|
207
|
+
signal: options.signal,
|
|
208
|
+
command: remoteFeedWatchCommand(device.platform),
|
|
209
|
+
backoffBaseMs: options.reconnectMs,
|
|
210
|
+
onLine: (line) => {
|
|
211
|
+
try {
|
|
212
|
+
const event = JSON.parse(line);
|
|
213
|
+
if (event.v !== 1)
|
|
214
|
+
return false;
|
|
177
215
|
forward(event);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
await new Promise((resolve) => { const timer = setTimeout(resolve, options.reconnectMs ?? 2_000); options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
|
|
187
|
-
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return false; /* protocol only */
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
onUnavailable: (reason) => options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason })),
|
|
223
|
+
});
|
|
188
224
|
});
|
|
189
225
|
await Promise.all([local, ...tasks]);
|
|
190
226
|
}
|
|
@@ -34,6 +34,12 @@ export interface SessionMirrorRow {
|
|
|
34
34
|
summaryChecklist?: import('./session/types.js').SessionChecklistItem[];
|
|
35
35
|
/** Lifecycle of the daemon-computed summary (PHNX-3939). */
|
|
36
36
|
summaryState?: import('./session/types.js').SummaryState;
|
|
37
|
+
/** Tidied latest user turn, so a peer row shows what the agent was asked (PHNX-3939). */
|
|
38
|
+
request?: import('./session/types.js').SessionRequest;
|
|
39
|
+
/** Bounded narration-anchored steps, so a peer row shows what the agent did. */
|
|
40
|
+
timeline?: import('./session/types.js').SessionTimeline;
|
|
41
|
+
/** Bounded file-change list for the peer row. */
|
|
42
|
+
files?: import('./session/types.js').SessionFiles;
|
|
37
43
|
capturedAt: number;
|
|
38
44
|
}
|
|
39
45
|
export interface FleetSharedDeviceState {
|
|
@@ -3,7 +3,7 @@ import { type PidSessionEntry } from './pid-registry.js';
|
|
|
3
3
|
import { type HookSessionIndex } from './hook-sessions.js';
|
|
4
4
|
import { type UserPromptKind } from './prompt.js';
|
|
5
5
|
import { type SessionState, type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
6
|
-
import { type SessionAgentId, type SessionAttachment, type SessionMeta } from './types.js';
|
|
6
|
+
import { type SessionAgentId, type SessionAttachment, type SessionFiles, type SessionMeta, type SessionRequest, type SessionTimeline } from './types.js';
|
|
7
7
|
import { type SessionProvenance } from './provenance.js';
|
|
8
8
|
import { type DeviceRegistry } from '../devices/registry.js';
|
|
9
9
|
import { type Presence } from './detached.js';
|
|
@@ -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' | 'firstUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
|
|
58
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | '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;
|
|
@@ -140,6 +140,13 @@ export interface ActiveSession {
|
|
|
140
140
|
topic?: string;
|
|
141
141
|
/** Full, cleaned first genuine user turn, backfilled from the session index. */
|
|
142
142
|
firstUserMessage?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Full, cleaned LATEST genuine user turn, backfilled from the session index
|
|
145
|
+
* beside {@link firstUserMessage}. This is what {@link deriveSessionRecap}
|
|
146
|
+
* classifies — a `/continue`d or redirected session's real request is the last
|
|
147
|
+
* thing the user said, not the first (PHNX-3939).
|
|
148
|
+
*/
|
|
149
|
+
lastUserMessage?: string;
|
|
143
150
|
/**
|
|
144
151
|
* The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
|
|
145
152
|
* The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
|
|
@@ -207,6 +214,22 @@ export interface ActiveSession {
|
|
|
207
214
|
spawnedTeam?: string;
|
|
208
215
|
/** Files/screenshots attached to the session prompt. */
|
|
209
216
|
attachments?: SessionAttachment[];
|
|
217
|
+
/**
|
|
218
|
+
* The session's operative request: the LATEST genuine user turn, tidied but
|
|
219
|
+
* never rewritten (PHNX-3939) — prose separated from screenshot paths, clip
|
|
220
|
+
* references, `@dir` mentions and pasted terminal echo. Folded by the daemon
|
|
221
|
+
* timeline pass and merged from the `session_timelines` cache; the sidebar's
|
|
222
|
+
* Request card reads it straight off the row.
|
|
223
|
+
*/
|
|
224
|
+
request?: SessionRequest;
|
|
225
|
+
/**
|
|
226
|
+
* Narration-anchored steps — what the agent has been doing, in its own words
|
|
227
|
+
* (PHNX-3939). Last 8 in full plus a fold of everything older. Produced by the
|
|
228
|
+
* daemon's timeline pass, never on the request path.
|
|
229
|
+
*/
|
|
230
|
+
timeline?: SessionTimeline;
|
|
231
|
+
/** Files the session created, modified or deleted; ≤ 8 rows plus a total. */
|
|
232
|
+
files?: SessionFiles;
|
|
210
233
|
/** Total tokens and elapsed duration from the indexed transcript. */
|
|
211
234
|
tokenCount?: number;
|
|
212
235
|
durationMs?: number;
|
|
@@ -906,13 +929,24 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
|
|
|
906
929
|
*/
|
|
907
930
|
export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
|
|
908
931
|
export declare function foldHostLink(rows: ActiveSession[]): void;
|
|
909
|
-
/**
|
|
910
|
-
|
|
932
|
+
/**
|
|
933
|
+
* Labels win, then the last assistant line, then the prompt.
|
|
934
|
+
*
|
|
935
|
+
* The prompt rung classifies the RAW latest genuine user turn, not the
|
|
936
|
+
* already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
|
|
937
|
+
* echo or a skill body became a session's displayed title: `extractSessionTopic`
|
|
938
|
+
* accepted text `cleanFirstUserMessage` rejected, and the recap then dressed up
|
|
939
|
+
* that scaffolding as the user's words. It also never saw the row's attachments,
|
|
940
|
+
* so an image-only turn could not be recognized. A row that has already been
|
|
941
|
+
* merged with a daemon-folded {@link ActiveSession.request} uses it directly.
|
|
942
|
+
*/
|
|
943
|
+
export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
|
|
911
944
|
title?: string;
|
|
912
945
|
recapSource?: RecapSource;
|
|
913
946
|
userPromptClean?: string;
|
|
914
947
|
userPromptKind?: UserPromptKind;
|
|
915
948
|
lastAgentLine?: string;
|
|
949
|
+
request?: SessionRequest;
|
|
916
950
|
};
|
|
917
951
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
918
952
|
export declare function foldRecap(rows: ActiveSession[]): void;
|
|
@@ -30,7 +30,7 @@ import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord
|
|
|
30
30
|
import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
|
|
31
31
|
import { buildRunNameMap } from './run-names.js';
|
|
32
32
|
import { latestSessionFileForCwd, findSessionsByShortIds, findSessionMachinesByIds, getSessionById } from './db.js';
|
|
33
|
-
import { extractSessionTopic, classifyUserPrompt } from './prompt.js';
|
|
33
|
+
import { extractSessionTopic, classifyUserPrompt, tidyRequest } from './prompt.js';
|
|
34
34
|
import { readSessionTailWithRaw } from './tail.js';
|
|
35
35
|
import { parseSession } from './parse.js';
|
|
36
36
|
import { computeTokPerSec } from './throughput.js';
|
|
@@ -151,6 +151,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
151
151
|
s.label = m.label;
|
|
152
152
|
if (!s.firstUserMessage && m.firstUserMessage)
|
|
153
153
|
s.firstUserMessage = m.firstUserMessage;
|
|
154
|
+
if (!s.lastUserMessage && m.lastUserMessage)
|
|
155
|
+
s.lastUserMessage = m.lastUserMessage;
|
|
154
156
|
if (!s.ticket && m.ticketId)
|
|
155
157
|
s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
|
|
156
158
|
if (!s.pr && m.prUrl)
|
|
@@ -2101,10 +2103,31 @@ function recapLine(s, max = 120) {
|
|
|
2101
2103
|
return undefined;
|
|
2102
2104
|
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
|
|
2103
2105
|
}
|
|
2104
|
-
/**
|
|
2106
|
+
/**
|
|
2107
|
+
* Labels win, then the last assistant line, then the prompt.
|
|
2108
|
+
*
|
|
2109
|
+
* The prompt rung classifies the RAW latest genuine user turn, not the
|
|
2110
|
+
* already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
|
|
2111
|
+
* echo or a skill body became a session's displayed title: `extractSessionTopic`
|
|
2112
|
+
* accepted text `cleanFirstUserMessage` rejected, and the recap then dressed up
|
|
2113
|
+
* that scaffolding as the user's words. It also never saw the row's attachments,
|
|
2114
|
+
* so an image-only turn could not be recognized. A row that has already been
|
|
2115
|
+
* merged with a daemon-folded {@link ActiveSession.request} uses it directly.
|
|
2116
|
+
*/
|
|
2105
2117
|
export function deriveSessionRecap(row) {
|
|
2106
2118
|
const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
|
|
2107
|
-
const
|
|
2119
|
+
const raw = row.lastUserMessage ?? row.firstUserMessage ?? row.topic ?? '';
|
|
2120
|
+
// No `turns` here on purpose: this tidy sees ONE turn, not the transcript, so
|
|
2121
|
+
// it cannot count them. The daemon fold sets it (see SessionRequest.turns).
|
|
2122
|
+
const tidied = row.request ?? tidyRequest(raw, { attachments: row.attachments });
|
|
2123
|
+
// No genuine turn to show (a synthetic-only transcript, or nothing indexed
|
|
2124
|
+
// yet): fall back to the legacy classifier over whatever `topic` holds rather
|
|
2125
|
+
// than showing an empty prompt rung.
|
|
2126
|
+
const fallback = tidied ? undefined : classifyUserPrompt(row.topic ?? '', {
|
|
2127
|
+
hasImageAttachment: row.attachments?.some((a) => a.mediaType?.startsWith('image/')),
|
|
2128
|
+
});
|
|
2129
|
+
const userPromptClean = tidied?.headline ?? fallback?.clean;
|
|
2130
|
+
const userPromptKind = tidied?.kind ?? fallback?.kind;
|
|
2108
2131
|
const label = recapLine(row.label);
|
|
2109
2132
|
const prompt = recapLine(userPromptClean || row.topic);
|
|
2110
2133
|
let title;
|
|
@@ -2121,7 +2144,14 @@ export function deriveSessionRecap(row) {
|
|
|
2121
2144
|
title = prompt;
|
|
2122
2145
|
recapSource = 'prompt';
|
|
2123
2146
|
}
|
|
2124
|
-
return {
|
|
2147
|
+
return {
|
|
2148
|
+
title,
|
|
2149
|
+
recapSource,
|
|
2150
|
+
userPromptClean: recapLine(userPromptClean),
|
|
2151
|
+
userPromptKind,
|
|
2152
|
+
lastAgentLine,
|
|
2153
|
+
...(tidied ? { request: tidied } : {}),
|
|
2154
|
+
};
|
|
2125
2155
|
}
|
|
2126
2156
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
2127
2157
|
export function foldRecap(rows) {
|
|
@@ -2132,6 +2162,8 @@ export function foldRecap(rows) {
|
|
|
2132
2162
|
s.userPromptClean = recap.userPromptClean;
|
|
2133
2163
|
s.userPromptKind = recap.userPromptKind;
|
|
2134
2164
|
s.lastAgentLine = recap.lastAgentLine;
|
|
2165
|
+
if (recap.request)
|
|
2166
|
+
s.request = recap.request;
|
|
2135
2167
|
}
|
|
2136
2168
|
}
|
|
2137
2169
|
/**
|
|
@@ -456,6 +456,16 @@ export function detectBashMilestone(command) {
|
|
|
456
456
|
if (lower.includes('pr merge'))
|
|
457
457
|
return { event: 'pr.merged', detail: 'gh pr merge' };
|
|
458
458
|
}
|
|
459
|
+
// The repo's own toolchain: spawning a team and rendering an artifact are the
|
|
460
|
+
// two things an operator looks for in a session's history alongside a commit
|
|
461
|
+
// or a PR, and both are shell-driven (PHNX-3939).
|
|
462
|
+
if (info.tool === 'agents' && info.subcommand === 'teams'
|
|
463
|
+
&& /\bteams\s+(create|add|start)\b/.test(lower)) {
|
|
464
|
+
return { event: 'team.spawned', detail: 'agents teams' };
|
|
465
|
+
}
|
|
466
|
+
if (/\bartifacts\s+render\b/.test(lower)) {
|
|
467
|
+
return { event: 'artifact.rendered', detail: 'artifacts render' };
|
|
468
|
+
}
|
|
459
469
|
return null;
|
|
460
470
|
}
|
|
461
471
|
/**
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* BM25 ranking, and label-first search for /rename'd sessions.
|
|
8
8
|
*/
|
|
9
9
|
import Database from '../sqlite.js';
|
|
10
|
-
import type { SessionAgentId, SessionCheckpoint, SessionChecklistItem, SessionEvent, SessionMeta, SummaryState } from './types.js';
|
|
10
|
+
import type { SessionAgentId, SessionCheckpoint, SessionChecklistItem, SessionEvent, SessionFiles, SessionMeta, SessionRequest, SessionTimeline, SummaryState } from './types.js';
|
|
11
11
|
import { type IndexedToolCall } from './tool-calls.js';
|
|
12
12
|
import { type ToolScanResumePoint } from './tool-store.js';
|
|
13
|
+
import { type TimelineState } from './timeline.js';
|
|
13
14
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
14
15
|
* assert against the constant instead of hardcoding a number that every bump
|
|
15
16
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
16
|
-
export declare const SCHEMA_VERSION =
|
|
17
|
+
export declare const SCHEMA_VERSION = 48;
|
|
17
18
|
/**
|
|
18
19
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
19
20
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -478,6 +479,46 @@ export declare function writeSessionSummary(entry: {
|
|
|
478
479
|
fileSize: number | null;
|
|
479
480
|
summary: SessionSummaryEntry;
|
|
480
481
|
}): void;
|
|
482
|
+
/**
|
|
483
|
+
* The daemon-computed timeline stored in `session_timelines` (PHNX-3939): the
|
|
484
|
+
* bounded projection a row merges, plus the request and files derived in the
|
|
485
|
+
* same fold.
|
|
486
|
+
*/
|
|
487
|
+
export interface SessionTimelineProjection {
|
|
488
|
+
timeline: SessionTimeline;
|
|
489
|
+
request?: SessionRequest;
|
|
490
|
+
files?: SessionFiles;
|
|
491
|
+
}
|
|
492
|
+
/** One folded timeline, as the pass produces it for writing. */
|
|
493
|
+
export interface SessionTimelineEntry extends SessionTimelineProjection {
|
|
494
|
+
state: TimelineState;
|
|
495
|
+
}
|
|
496
|
+
/** One cached row as READ back: the folded entry plus when it was folded. */
|
|
497
|
+
export interface SessionTimelineCacheRow extends SessionTimelineEntry {
|
|
498
|
+
/** Epoch ms this row was folded — the pass's re-parse rate limit reads it. */
|
|
499
|
+
computedAt: number;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Read the projection AND the resume state for one session, whatever the
|
|
503
|
+
* transcript stamp — the daemon pass's read, which needs the state to continue
|
|
504
|
+
* from and re-stats the file itself.
|
|
505
|
+
*/
|
|
506
|
+
export declare function readSessionTimelineEntry(id: string): SessionTimelineCacheRow | undefined;
|
|
507
|
+
/**
|
|
508
|
+
* Read only the bounded projection for one session — the display merge, on the
|
|
509
|
+
* read path. Deliberately does NOT parse the resume state: a live row needs the
|
|
510
|
+
* 8 steps and the request, never the fold's bookkeeping.
|
|
511
|
+
*/
|
|
512
|
+
export declare function readSessionTimelineAny(id: string): SessionTimelineProjection | undefined;
|
|
513
|
+
/** Persist a folded timeline against the exact transcript bytes it was folded to. */
|
|
514
|
+
export declare function writeSessionTimeline(entry: {
|
|
515
|
+
id: string;
|
|
516
|
+
fileMtimeMs: number | null;
|
|
517
|
+
fileSize: number | null;
|
|
518
|
+
timeline: SessionTimelineEntry;
|
|
519
|
+
/** Fold time. Injected by the pass so its re-parse interval is testable without the wall clock. */
|
|
520
|
+
computedAtMs?: number;
|
|
521
|
+
}): void;
|
|
481
522
|
/** A local-origin session, projected to the compact fields the fleet mirror publishes. */
|
|
482
523
|
export interface LocalMirrorSource {
|
|
483
524
|
id: string;
|
|
@@ -495,6 +536,8 @@ export interface LocalMirrorSource {
|
|
|
495
536
|
prUrl: string | null;
|
|
496
537
|
/** Daemon-computed summary (PHNX-3939), so peers carry it without a transcript. */
|
|
497
538
|
summary: SessionSummaryEntry | null;
|
|
539
|
+
/** Daemon-folded request/timeline/files (PHNX-3939), same reason as {@link summary}. */
|
|
540
|
+
timeline: SessionTimelineProjection | null;
|
|
498
541
|
}
|
|
499
542
|
/**
|
|
500
543
|
* The most-recently-active sessions whose transcript is genuinely local to this
|
|
@@ -521,6 +564,8 @@ export interface MirrorSessionUpsert {
|
|
|
521
564
|
prUrl?: string | null;
|
|
522
565
|
/** Daemon-computed summary carried from the publishing peer (PHNX-3939). */
|
|
523
566
|
summary?: SessionSummaryEntry | null;
|
|
567
|
+
/** Daemon-folded request/timeline/files carried from the publishing peer (PHNX-3939). */
|
|
568
|
+
timeline?: SessionTimelineProjection | null;
|
|
524
569
|
}
|
|
525
570
|
/**
|
|
526
571
|
* Write one peer session's digest into the local `sessions` index as a mirror
|