@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/commands/accounts.js +34 -6
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/commands/view.js +3 -6
  6. package/dist/lib/account-registry.d.ts +40 -3
  7. package/dist/lib/account-registry.js +89 -18
  8. package/dist/lib/accounts/connect.d.ts +6 -0
  9. package/dist/lib/accounts/connect.js +70 -0
  10. package/dist/lib/claude-statusline.d.ts +13 -5
  11. package/dist/lib/claude-statusline.js +19 -6
  12. package/dist/lib/daemon-ticks.js +23 -2
  13. package/dist/lib/devices/fleet-inventory.js +3 -4
  14. package/dist/lib/devices/harness-inventory.js +4 -5
  15. package/dist/lib/feed/activity-stream.d.ts +60 -0
  16. package/dist/lib/feed/activity-stream.js +271 -0
  17. package/dist/lib/feed/activity.d.ts +7 -0
  18. package/dist/lib/feed/activity.js +103 -13
  19. package/dist/lib/feed/watch.d.ts +9 -3
  20. package/dist/lib/feed/watch.js +72 -36
  21. package/dist/lib/fleet-shared-state.d.ts +6 -0
  22. package/dist/lib/session/active.d.ts +38 -4
  23. package/dist/lib/session/active.js +36 -4
  24. package/dist/lib/session/bash-command.js +10 -0
  25. package/dist/lib/session/db.d.ts +47 -2
  26. package/dist/lib/session/db.js +131 -4
  27. package/dist/lib/session/mirror.d.ts +5 -0
  28. package/dist/lib/session/mirror.js +166 -0
  29. package/dist/lib/session/parse.d.ts +49 -0
  30. package/dist/lib/session/parse.js +324 -30
  31. package/dist/lib/session/prompt.d.ts +93 -2
  32. package/dist/lib/session/prompt.js +266 -15
  33. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  34. package/dist/lib/session/remote/peer-stream.js +142 -0
  35. package/dist/lib/session/remote/watch.d.ts +1 -1
  36. package/dist/lib/session/remote/watch.js +41 -42
  37. package/dist/lib/session/session-cache.d.ts +9 -0
  38. package/dist/lib/session/session-cache.js +40 -2
  39. package/dist/lib/session/timeline-pass.d.ts +129 -0
  40. package/dist/lib/session/timeline-pass.js +323 -0
  41. package/dist/lib/session/timeline.d.ts +182 -0
  42. package/dist/lib/session/timeline.js +636 -0
  43. package/dist/lib/session/types.d.ts +155 -1
  44. package/dist/lib/summarizer/pass.d.ts +2 -0
  45. package/dist/lib/summarizer/pass.js +14 -1
  46. package/dist/lib/summarizer/summarize.d.ts +7 -0
  47. package/dist/lib/summarizer/summarize.js +3 -0
  48. package/package.json +1 -1
@@ -1,12 +1,20 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import { nativeAccountCapability, nativeAccountNamingRefusal } from '../account-capabilities.js';
3
3
  import { listNativeAccounts } from '../account-registry.js';
4
+ import { providerAuthenticatesHarness } from '../account-provider-registry.js';
5
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
6
+ import { machineId } from '../machine-id.js';
4
7
  import { readMeta } from '../state.js';
5
8
  import { acquireAuthOperationLock } from './auth-operation-lock.js';
6
9
  /**
7
10
  * `agents accounts connect <harness> [name]` — the stable-account front door
8
11
  * (PHNX-3940).
9
12
  *
13
+ * Headed devices only. A worker (or unmarked box) is refused before any slot,
14
+ * install, or browser login — workers never run an interactive OAuth flow
15
+ * (credential-management.md invariant 7). Add the account on a personal/desktop
16
+ * box; workers are provisioned from the durable credential.
17
+ *
10
18
  * An account is stable INDEPENDENT of releases: connecting a NEW account mints a
11
19
  * fresh opaque installation label, installs the current release into that
12
20
  * label's isolated home (even when the same release is already installed under
@@ -75,6 +83,65 @@ export function assertConnectSupported(agent) {
75
83
  if (reason)
76
84
  throw new Error(reason);
77
85
  }
86
+ /**
87
+ * Native API-key provider for a harness that `connect` can drive AND that
88
+ * workers provision from a portable credential (credential-management.md
89
+ * invariant 7). The id is the registry adapter that authenticates this harness
90
+ * with `api-key` — not a parallel invention. Claude is a setup-token, not an
91
+ * API key, and is handled separately. Only LOGIN_INVOCATIONS harnesses belong
92
+ * here; a mapping for a harness connect cannot drive is dead code.
93
+ */
94
+ const NATIVE_API_KEY_PROVIDER = {
95
+ codex: 'openai',
96
+ };
97
+ function nativeApiKeyProvider(agent) {
98
+ const id = NATIVE_API_KEY_PROVIDER[agent];
99
+ if (!id)
100
+ return null;
101
+ if (!providerAuthenticatesHarness(id, 'api-key', agent)) {
102
+ throw new Error(`Internal: provider '${id}' does not authenticate ${agent} with an api-key.`);
103
+ }
104
+ return id;
105
+ }
106
+ function workerCredentialHint(agent, name, device) {
107
+ if (agent === 'claude')
108
+ return 'the setup-token minted by agents accounts mint claude';
109
+ const provider = nativeApiKeyProvider(agent);
110
+ if (provider) {
111
+ const account = name ?? '<name>';
112
+ return `a provider API key — agents accounts add ${account} --provider ${provider} --auth api-key then agents accounts sync ${account} ${device}`;
113
+ }
114
+ // LOGIN_INVOCATIONS today is {claude, codex}; this arm is the loud failure
115
+ // if a future harness is added there without a portable-credential mapping.
116
+ // Names no command — a guessed `fleet login <harness>` is not a real invocation.
117
+ return `no portable credential for ${agent}`;
118
+ }
119
+ /**
120
+ * Named reason connect refuses on a non-headed device, or null when this box
121
+ * may run an interactive login. Workers never mint a native OAuth login
122
+ * (credential-management.md invariant 7 + Provisioning model).
123
+ */
124
+ export function connectWorkerRefusal(agent, name) {
125
+ const role = selfConfiguredDeviceRole();
126
+ if (isHeadedDeviceRole(role))
127
+ return null;
128
+ const device = machineId();
129
+ const roleLabel = role ?? 'unmarked';
130
+ const selector = name ? `${agent}#${name}` : agent;
131
+ const connectCmd = name
132
+ ? `agents accounts connect ${agent} ${name}`
133
+ : `agents accounts connect ${agent} <name>`;
134
+ return `${selector}: this device is a worker (role ${roleLabel}) and never runs an interactive login. `
135
+ + `Add the account on your personal device with \`${connectCmd}\`; `
136
+ + `workers are provisioned from the durable credential automatically `
137
+ + `(${agent}: ${workerCredentialHint(agent, name, device)}). `
138
+ + `To mark this box as your interactive seat: agents devices role ${device} personal.`;
139
+ }
140
+ function assertConnectAllowedOnThisDevice(agent, name) {
141
+ const reason = connectWorkerRefusal(agent, name);
142
+ if (reason)
143
+ throw new Error(reason);
144
+ }
78
145
  export function loginInvocation(agent) {
79
146
  const invocation = LOGIN_INVOCATIONS[agent];
80
147
  if (!invocation)
@@ -194,6 +261,9 @@ export function findConnectAccount(agent, name, meta) {
194
261
  */
195
262
  export async function runConnect(agent, name, opts, runners) {
196
263
  assertConnectSupported(agent);
264
+ // Owner rule (invariant 7): a worker NEVER runs an interactive login. Refuse
265
+ // before the lock, slot, install, or browser — no side effects on a worker.
266
+ assertConnectAllowedOnThisDevice(agent, name);
197
267
  // Acquire the per-harness auth-operation mutex BEFORE any meta read, slot
198
268
  // allocation, or name validation — two parallel connects for the same harness
199
269
  // would otherwise both see "name available" and "slot free", then race to
@@ -1,4 +1,5 @@
1
1
  import { type ClaudeHomeIdentity } from './agent-spec/agents.js';
2
+ import { type NativeAccount } from './account-registry.js';
2
3
  export declare const CLAUDE_STATUSLINE_COMMAND = "agents __claude-statusline";
3
4
  /**
4
5
  * True when `command` re-invokes THIS status-line producer (our private
@@ -45,12 +46,19 @@ export declare function claudeHomeFromEnv(env: NodeJS.ProcessEnv): string;
45
46
  */
46
47
  export declare function readClaudeIdentity(claudeHome: string): ClaudeHomeIdentity | null;
47
48
  /**
48
- * Format the signed-in account as a statusline part ('' when never signed in),
49
- * with the label every other account-aware surface (`agents view`, `agents
50
- * accounts`) uses — the email, plus the org name for a multi-seat Team/Enterprise
51
- * seat.
49
+ * The registered native account for the running Claude's identity (`agents
50
+ * accounts` — `work`, `dev`, …), or null when that login is unnamed.
52
51
  */
53
- export declare function formatAccountPart(identity: ClaudeHomeIdentity | null): string;
52
+ export declare function resolveNativeAccount(identity: ClaudeHomeIdentity | null): NativeAccount | null;
53
+ /**
54
+ * Format the signed-in account as a statusline part ('' when never signed in).
55
+ * A named login renders its registered NAME — the short handle the owner chose
56
+ * (`work`, `dev`), which is what tells eight same-harness logins apart at a
57
+ * glance. An unnamed login renders the label every other account-aware surface
58
+ * (`agents view`, `agents accounts`) uses: the email, plus the org name for a
59
+ * multi-seat Team/Enterprise seat.
60
+ */
61
+ export declare function formatAccountPart(identity: ClaudeHomeIdentity | null, account: NativeAccount | null): string;
54
62
  export declare function renderDelegate(payload: string, versionHome: string): string;
55
63
  /**
56
64
  * Format a reminder as a dimmed statusline part (empty string when none). The
@@ -3,9 +3,11 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { spawnSync } from 'child_process';
5
5
  import { accountDisplayLabel, readClaudeHomeConfig, } from './agent-spec/agents.js';
6
+ import { findNativeAccountByIdentity } from './account-registry.js';
6
7
  import { atomicWriteFileSync } from './fs-atomic.js';
7
8
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
8
9
  import { loadReminders, pickReminderForSession } from './reminders.js';
10
+ import { readMeta } from './state.js';
9
11
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
10
12
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
11
13
  // The private subcommand this feature runs. It is only ever invoked internally,
@@ -97,14 +99,25 @@ export function readClaudeIdentity(claudeHome) {
97
99
  return readClaudeHomeConfig(claudeHome)?.identity ?? null;
98
100
  }
99
101
  /**
100
- * Format the signed-in account as a statusline part ('' when never signed in),
101
- * with the label every other account-aware surface (`agents view`, `agents
102
- * accounts`) uses — the email, plus the org name for a multi-seat Team/Enterprise
103
- * seat.
102
+ * The registered native account for the running Claude's identity (`agents
103
+ * accounts` — `work`, `dev`, …), or null when that login is unnamed.
104
104
  */
105
- export function formatAccountPart(identity) {
105
+ export function resolveNativeAccount(identity) {
106
+ return identity ? findNativeAccountByIdentity(readMeta(), 'claude', identity) : null;
107
+ }
108
+ /**
109
+ * Format the signed-in account as a statusline part ('' when never signed in).
110
+ * A named login renders its registered NAME — the short handle the owner chose
111
+ * (`work`, `dev`), which is what tells eight same-harness logins apart at a
112
+ * glance. An unnamed login renders the label every other account-aware surface
113
+ * (`agents view`, `agents accounts`) uses: the email, plus the org name for a
114
+ * multi-seat Team/Enterprise seat.
115
+ */
116
+ export function formatAccountPart(identity, account) {
106
117
  if (!identity)
107
118
  return '';
119
+ if (account)
120
+ return account.name;
108
121
  return accountDisplayLabel({ ...identity, signedIn: true });
109
122
  }
110
123
  function delegatePath(versionHome) {
@@ -194,7 +207,7 @@ export async function runClaudeStatusLine() {
194
207
  const identity = readClaudeIdentity(claudeHomeFromEnv(process.env));
195
208
  if (versionHome)
196
209
  ingestClaudeStatusLineUsage(payload, identity);
197
- process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : '', resolveReminderPart(payload.session_id), formatAccountPart(identity)));
210
+ process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : '', resolveReminderPart(payload.session_id), formatAccountPart(identity, resolveNativeAccount(identity))));
198
211
  return 0;
199
212
  }
200
213
  export function installClaudeStatusLine(versionHome) {
@@ -162,8 +162,29 @@ export async function runActiveSessionsWarmTick(opts = {}) {
162
162
  console.log('active-sessions warm: idle (no recent reader), skipping gather');
163
163
  return { sessions: 0 };
164
164
  }
165
- const r = await publishLocalActiveSessions({ gather: opts.gather, nowMs });
166
- console.log(`active-sessions warm: ${r.sessions.length} session(s) published`);
165
+ // ONE gather per tick, then fold, then publish. The order is load-bearing:
166
+ // the publish's per-row merge reads the timeline cache, so folding first is
167
+ // what puts a CURRENT timeline on the row this tick writes to the journal
168
+ // rather than the previous tick's (PHNX-3939).
169
+ const gather = opts.gather ?? (async () => {
170
+ const { getActiveSessions } = await import('./session/active.js');
171
+ return getActiveSessions({ localOnly: true });
172
+ });
173
+ const gathered = await gather();
174
+ // Bounded so it can never own the tick: at most 8 sessions, and for the
175
+ // resumable harnesses only the bytes each transcript grew by. A failure here
176
+ // must not cost the publish.
177
+ const { runTimelinePassSync } = await import('./session/timeline-pass.js');
178
+ let timeline = { computed: 0, reused: 0, skipped: 0 };
179
+ try {
180
+ timeline = runTimelinePassSync({ sessions: gathered, nowMs });
181
+ }
182
+ catch (err) {
183
+ console.log(`active-sessions warm: timeline pass failed: ${err.message}`);
184
+ }
185
+ const r = await publishLocalActiveSessions({ gather: async () => gathered, nowMs });
186
+ console.log(`active-sessions warm: ${r.sessions.length} session(s) published; `
187
+ + `timeline ${timeline.computed} folded, ${timeline.reused} current, ${timeline.skipped} skipped`);
167
188
  return { sessions: r.sessions.length };
168
189
  }
169
190
  /**
@@ -12,7 +12,7 @@ import { getAvailableResources, getVersionHomePath, isVersionIsolated, listInsta
12
12
  import { supports } from '../capabilities.js';
13
13
  import { checkVersionHookWiring } from '../hooks/install.js';
14
14
  import { getUserAgentsDir, getSystemAgentsDir, readMeta } from '../state.js';
15
- import { listNativeAccounts } from '../account-registry.js';
15
+ import { findNativeAccountByIdentity } from '../account-registry.js';
16
16
  import { readRepoState } from '../git.js';
17
17
  import { ALL_AGENT_IDS, accountDisplayLabel, credentialPresence, getAccountInfo, supportsAccountInspection, } from '../agents.js';
18
18
  import { FLEET_RESOURCE_KINDS, } from './fleet-divergence.js';
@@ -46,9 +46,8 @@ export async function collectLocalFleetSignIn() {
46
46
  signedIn = info.signedIn;
47
47
  // Prefix the durable account name when this identity has been named.
48
48
  const display = accountDisplayLabel(info);
49
- const identityKey = info.accountKey ?? info.email?.toLowerCase();
50
- const saved = identityKey ? listNativeAccounts(readMeta()).find(item => item.agent === agent && item.identityKey === identityKey) : undefined;
51
- account = (saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display) || null;
49
+ const saved = findNativeAccountByIdentity(readMeta(), agent, info);
50
+ account = (saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display) || null;
52
51
  }
53
52
  catch {
54
53
  /* advisory only — treat as logged out, provability decided below */
@@ -18,7 +18,7 @@ import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.j
18
18
  import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, } from '../accounting/usage.js';
19
19
  import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
20
20
  import { readMeta } from '../state.js';
21
- import { listNativeAccounts } from '../account-registry.js';
21
+ import { findNativeAccountByIdentity } from '../account-registry.js';
22
22
  /**
23
23
  * Roll one usage snapshot into a {@link QuotaSummary}. Mirrors the blocking-window
24
24
  * selection of {@link deriveUsageStatusFromSnapshot} (the model-specific
@@ -118,7 +118,7 @@ export async function collectLocalHarnessInventory(opts) {
118
118
  const usageByKey = usageInputs.length
119
119
  ? (await getUsageInfoByIdentity(usageInputs, opts?.refresh ? { forceRefresh: true } : undefined)).usageByKey
120
120
  : new Map();
121
- const savedNative = listNativeAccounts(readMeta());
121
+ const meta = readMeta();
122
122
  return pending.map(({ agent, version, info }) => {
123
123
  const key = getUsageLookupKey(info);
124
124
  const usage = key ? usageByKey.get(key) : undefined;
@@ -130,9 +130,8 @@ export async function collectLocalHarnessInventory(opts) {
130
130
  const signedIn = !!info?.signedIn;
131
131
  const { ready, reason } = computeReady(signedIn, quota);
132
132
  const display = info ? accountDisplayLabel(info) || null : null;
133
- const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
134
- const saved = identityKey ? savedNative.find(item => item.agent === agent && item.identityKey === identityKey) : undefined;
135
- const account = saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display;
133
+ const saved = findNativeAccountByIdentity(meta, agent, info);
134
+ const account = saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display;
136
135
  return { agent, version, account, signedIn, quota, ready, reason };
137
136
  });
138
137
  }
@@ -0,0 +1,60 @@
1
+ import { type ActivityEvent } from './activity.js';
2
+ /** How often the stream falls back to a full directory stat sweep. */
3
+ export declare const ACTIVITY_SWEEP_MS = 5000;
4
+ /** Bytes behind the cursor re-verified before appended bytes are trusted. */
5
+ export declare const ACTIVITY_ANCHOR_BYTES = 64;
6
+ export interface ActivityStreamOptions {
7
+ /** Override the activity dir (tests). */
8
+ root?: string;
9
+ /**
10
+ * Newest bytes read from one file in one tick. A burst larger than this keeps
11
+ * only the tail, exactly as `readRecentActivity`'s bounded tail does.
12
+ */
13
+ maxBytesPerRead?: number;
14
+ /** Full stat sweep cadence, covering anything the directory watcher misses. */
15
+ sweepMs?: number;
16
+ /** Subscribe to directory change notifications (default true). */
17
+ watch?: boolean;
18
+ }
19
+ /**
20
+ * A cursor over the activity directory. Construct it at the moment the caller's
21
+ * activity cursor starts, then call {@link read} once per tick.
22
+ */
23
+ export declare class ActivityStream {
24
+ private readonly dir;
25
+ private readonly maxBytesPerRead;
26
+ private readonly sweepMs;
27
+ private readonly cursors;
28
+ private readonly dirty;
29
+ private watcher?;
30
+ private watchRequested;
31
+ private lastSweepMs;
32
+ /** False during the opening scan, so it registers history without reading it. */
33
+ private started;
34
+ /** Bytes read from activity logs since construction. Observability + tests. */
35
+ bytesRead: number;
36
+ constructor(options?: ActivityStreamOptions);
37
+ /**
38
+ * Events appended since the last call, newest first, filtered to `sinceMs`
39
+ * inclusive — the same shape and order `readRecentActivity({ sinceMs })`
40
+ * returns for those events.
41
+ */
42
+ read(sinceMs: number, nowMs?: number): ActivityEvent[];
43
+ /** Release the directory watcher. Safe to call more than once. */
44
+ close(): void;
45
+ /** Which log names could have changed since the previous tick. */
46
+ private candidates;
47
+ /**
48
+ * Stat every log, marking the ones that could have changed. Opens nothing: a
49
+ * log the opening scan sees is registered past its own bytes, so history is
50
+ * never replayed onto the stream.
51
+ */
52
+ private sweep;
53
+ private statOf;
54
+ /** A cursor starting at a bounded tail of the file as it stands right now. */
55
+ private freshCursor;
56
+ /** Read and parse only the bytes appended to one log since its cursor. */
57
+ private readFile;
58
+ private readRange;
59
+ private armWatcher;
60
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Incremental reader for the activity log directory.
3
+ *
4
+ * `readRecentActivity` answers "what happened since T?" by tailing and parsing
5
+ * every session log in the directory. That is the right shape for a one-shot
6
+ * `agents feed` render and the wrong shape for `agents feed watch`, which asks
7
+ * the same question twice a second for as long as a VS Code window is open: on
8
+ * a real operator box the directory holds 1,437 logs / 64 MB, so every tick
9
+ * re-read and re-parsed the whole corpus to emit, almost always, nothing.
10
+ *
11
+ * This reader keeps a per-file cursor instead. The opening scan opens no files
12
+ * at all — it records each log's size, inode, and mtime — so only bytes appended
13
+ * *after* the stream started are ever read. Steady-state cost is one `stat` per
14
+ * changed file plus a parse of exactly the appended bytes.
15
+ *
16
+ * The emitted events, their order, and the `sinceMs` filter match
17
+ * `readRecentActivity` for everything appended while the stream is open; the
18
+ * equivalence is pinned by `activity-stream.test.ts`.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { getActivityDir } from '../state.js';
23
+ import { ACTIVITY_TAIL_BYTES, parseActivityLine } from './activity.js';
24
+ /** How often the stream falls back to a full directory stat sweep. */
25
+ export const ACTIVITY_SWEEP_MS = 5_000;
26
+ /** Bytes behind the cursor re-verified before appended bytes are trusted. */
27
+ export const ACTIVITY_ANCHOR_BYTES = 64;
28
+ const NEWLINE = 0x0a;
29
+ const EMPTY = Buffer.alloc(0);
30
+ /** Could this file have changed since its cursor last looked? */
31
+ function changed(cursor, stat) {
32
+ return stat.identity !== cursor.identity
33
+ || stat.size !== cursor.size
34
+ || stat.mtimeNs !== cursor.mtimeNs
35
+ || stat.ctimeNs !== cursor.ctimeNs;
36
+ }
37
+ /**
38
+ * A cursor over the activity directory. Construct it at the moment the caller's
39
+ * activity cursor starts, then call {@link read} once per tick.
40
+ */
41
+ export class ActivityStream {
42
+ dir;
43
+ maxBytesPerRead;
44
+ sweepMs;
45
+ cursors = new Map();
46
+ dirty = new Set();
47
+ watcher;
48
+ watchRequested;
49
+ lastSweepMs = 0;
50
+ /** False during the opening scan, so it registers history without reading it. */
51
+ started = false;
52
+ /** Bytes read from activity logs since construction. Observability + tests. */
53
+ bytesRead = 0;
54
+ constructor(options = {}) {
55
+ this.dir = options.root ?? getActivityDir();
56
+ this.maxBytesPerRead = options.maxBytesPerRead ?? ACTIVITY_TAIL_BYTES;
57
+ this.sweepMs = options.sweepMs ?? ACTIVITY_SWEEP_MS;
58
+ this.watchRequested = options.watch ?? true;
59
+ this.sweep(Date.now());
60
+ this.armWatcher();
61
+ }
62
+ /**
63
+ * Events appended since the last call, newest first, filtered to `sinceMs`
64
+ * inclusive — the same shape and order `readRecentActivity({ sinceMs })`
65
+ * returns for those events.
66
+ */
67
+ read(sinceMs, nowMs = Date.now()) {
68
+ const out = [];
69
+ for (const name of this.candidates(nowMs)) {
70
+ for (const event of this.readFile(name)) {
71
+ const at = Date.parse(event.ts);
72
+ if (Number.isFinite(at) && at >= sinceMs)
73
+ out.push(event);
74
+ }
75
+ }
76
+ out.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
77
+ return out;
78
+ }
79
+ /** Release the directory watcher. Safe to call more than once. */
80
+ close() {
81
+ this.watchRequested = false;
82
+ this.watcher?.close();
83
+ this.watcher = undefined;
84
+ }
85
+ /** Which log names could have changed since the previous tick. */
86
+ candidates(nowMs) {
87
+ // A watcher that never armed (unsupported filesystem, or a directory that
88
+ // did not exist at construction) means every tick sweeps. That is the
89
+ // fallback: never a silent no-op that would drop events.
90
+ if (!this.watcher)
91
+ this.armWatcher();
92
+ if (!this.watcher || nowMs - this.lastSweepMs >= this.sweepMs)
93
+ this.sweep(nowMs);
94
+ const names = [...this.dirty];
95
+ this.dirty.clear();
96
+ return names;
97
+ }
98
+ /**
99
+ * Stat every log, marking the ones that could have changed. Opens nothing: a
100
+ * log the opening scan sees is registered past its own bytes, so history is
101
+ * never replayed onto the stream.
102
+ */
103
+ sweep(nowMs) {
104
+ this.lastSweepMs = nowMs;
105
+ let names;
106
+ try {
107
+ names = fs.readdirSync(this.dir).filter((name) => name.endsWith('.jsonl'));
108
+ }
109
+ catch {
110
+ return; // The directory appears with the first logged event.
111
+ }
112
+ const seen = new Set();
113
+ for (const name of names) {
114
+ seen.add(name);
115
+ const stat = this.statOf(name);
116
+ if (!stat)
117
+ continue;
118
+ const cursor = this.cursors.get(name);
119
+ if (!cursor) {
120
+ // A log first seen after the opening scan is new work: left
121
+ // unregistered so `readFile` opens it from a bounded tail.
122
+ if (this.started)
123
+ this.dirty.add(name);
124
+ else
125
+ this.cursors.set(name, {
126
+ identity: stat.identity, offset: stat.size, partial: EMPTY, partialIsFragment: false,
127
+ anchor: EMPTY, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
128
+ });
129
+ continue;
130
+ }
131
+ if (changed(cursor, stat))
132
+ this.dirty.add(name);
133
+ }
134
+ // A cursor is dropped only when its log is gone. There is deliberately no
135
+ // size cap: the map cannot outgrow the directory this sweep already had to
136
+ // enumerate, so a cap bounds nothing the readdir does not — while dropping
137
+ // a live log's cursor would re-register it as new work on the next sweep
138
+ // and replay a bounded tail of it onto the stream as duplicates.
139
+ for (const name of [...this.cursors.keys()])
140
+ if (!seen.has(name))
141
+ this.cursors.delete(name);
142
+ this.started = true;
143
+ }
144
+ statOf(name) {
145
+ try {
146
+ const st = fs.statSync(path.join(this.dir, name), { bigint: true });
147
+ return {
148
+ identity: `${st.dev}:${st.ino}`,
149
+ size: Number(st.size),
150
+ mtimeNs: Number(st.mtimeNs),
151
+ ctimeNs: Number(st.ctimeNs),
152
+ };
153
+ }
154
+ catch {
155
+ return undefined; // Deleted between readdir and stat.
156
+ }
157
+ }
158
+ /** A cursor starting at a bounded tail of the file as it stands right now. */
159
+ freshCursor(stat) {
160
+ const offset = Math.max(0, stat.size - this.maxBytesPerRead);
161
+ return {
162
+ identity: stat.identity, offset, partial: EMPTY, partialIsFragment: offset > 0,
163
+ anchor: EMPTY, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
164
+ };
165
+ }
166
+ /** Read and parse only the bytes appended to one log since its cursor. */
167
+ readFile(name, restarted = false) {
168
+ const stat = this.statOf(name);
169
+ if (!stat) {
170
+ this.cursors.delete(name);
171
+ return [];
172
+ }
173
+ let cursor = this.cursors.get(name);
174
+ // Unseen, replaced, truncated, or rewritten in place at the same length:
175
+ // restart from a bounded tail of the file as it now stands. The caller's
176
+ // `sinceMs` drops whatever predates the stream.
177
+ //
178
+ // The same-length case is why ctime is tracked. Growth is caught by the
179
+ // 64-byte anchor and a shrink by the offset compare, but a rewrite that
180
+ // lands on exactly the previous size moves neither, and the early return
181
+ // below would otherwise retire the file for good with content unread.
182
+ if (!cursor || cursor.identity !== stat.identity || stat.size < cursor.offset
183
+ || (stat.size === cursor.size && stat.ctimeNs !== cursor.ctimeNs)) {
184
+ cursor = this.freshCursor(stat);
185
+ this.cursors.set(name, cursor);
186
+ }
187
+ cursor.size = stat.size;
188
+ cursor.mtimeNs = stat.mtimeNs;
189
+ cursor.ctimeNs = stat.ctimeNs;
190
+ if (stat.size <= cursor.offset)
191
+ return [];
192
+ // A burst larger than the budget keeps the newest bytes; the skipped span is
193
+ // exactly what the bounded-tail reader would have dropped as well.
194
+ const start = Math.max(cursor.offset, stat.size - this.maxBytesPerRead);
195
+ if (start > cursor.offset) {
196
+ cursor.partial = EMPTY;
197
+ cursor.partialIsFragment = true;
198
+ cursor.anchor = EMPTY;
199
+ }
200
+ const verify = Math.min(cursor.anchor.length, start);
201
+ const buf = this.readRange(name, start - verify, stat.size - start + verify);
202
+ if (buf === undefined)
203
+ return []; // Transient I/O error: retry next tick.
204
+ if (verify > 0 && !buf.subarray(0, verify).equals(cursor.anchor.subarray(cursor.anchor.length - verify))) {
205
+ // The bytes behind the cursor changed, so this file was rewritten rather
206
+ // than appended to. Restart it once from a bounded tail.
207
+ if (restarted)
208
+ return [];
209
+ this.cursors.delete(name);
210
+ return this.readFile(name, true);
211
+ }
212
+ const fresh = buf.subarray(verify);
213
+ cursor.offset = stat.size;
214
+ cursor.anchor = Buffer.concat([cursor.anchor, fresh]).subarray(-ACTIVITY_ANCHOR_BYTES);
215
+ const chunk = Buffer.concat([cursor.partial, fresh]);
216
+ const fragment = cursor.partialIsFragment;
217
+ const end = chunk.lastIndexOf(NEWLINE);
218
+ // Hold an unterminated trailing line: the writer appends whole
219
+ // newline-terminated records (`appendActivityEvent`), so a tail without a
220
+ // newline is a write in progress and completes on a later tick.
221
+ cursor.partial = end < 0 ? chunk : chunk.subarray(end + 1);
222
+ cursor.partialIsFragment = end < 0 ? fragment : false;
223
+ if (end < 0)
224
+ return [];
225
+ const lines = chunk.subarray(0, end).toString('utf-8').split('\n');
226
+ // A range that began mid-file starts inside a record; that leading fragment
227
+ // is not one, exactly as the bounded-tail reader drops it.
228
+ if (fragment)
229
+ lines.shift();
230
+ const events = [];
231
+ for (const line of lines) {
232
+ const event = parseActivityLine(line);
233
+ if (event)
234
+ events.push(event);
235
+ }
236
+ return events;
237
+ }
238
+ readRange(name, start, length) {
239
+ let fd;
240
+ try {
241
+ fd = fs.openSync(path.join(this.dir, name), 'r');
242
+ const buf = Buffer.allocUnsafe(length);
243
+ const read = fs.readSync(fd, buf, 0, length, start);
244
+ this.bytesRead += read;
245
+ return buf.subarray(0, read);
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ finally {
251
+ if (fd !== undefined)
252
+ fs.closeSync(fd);
253
+ }
254
+ }
255
+ armWatcher() {
256
+ if (!this.watchRequested || this.watcher)
257
+ return;
258
+ try {
259
+ this.watcher = fs.watch(this.dir, (_event, name) => {
260
+ if (typeof name === 'string' && name.endsWith('.jsonl'))
261
+ this.dirty.add(name);
262
+ });
263
+ // A watch error (the directory is removed) degrades to sweeping rather
264
+ // than taking down the watcher process.
265
+ this.watcher.on('error', () => { this.watcher?.close(); this.watcher = undefined; });
266
+ }
267
+ catch {
268
+ this.watcher = undefined;
269
+ }
270
+ }
271
+ }
@@ -129,6 +129,13 @@ export declare function appendActivityEvent(event: Omit<ActivityEvent, 'v' | 'ti
129
129
  v?: number;
130
130
  tier?: ActivityTier;
131
131
  }, root?: string): void;
132
+ /**
133
+ * Parse one activity log line. Tolerant by design: a blank, corrupt, or
134
+ * half-written line is skipped rather than failing the whole read.
135
+ */
136
+ export declare function parseActivityLine(line: string): ActivityEvent | undefined;
137
+ /** Per-session byte budget for a bounded tail read of an activity log. */
138
+ export declare const ACTIVITY_TAIL_BYTES: number;
132
139
  /** Read all events for one session (bounded tail). */
133
140
  export declare function readSessionActivity(sessionId: string, root?: string, maxBytes?: number): ActivityEvent[];
134
141
  /** List session ids that have an activity log. */