@phnx-labs/agents-cli 1.22.83 → 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 (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/commands/accounts.js +1 -1
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/lib/accounts/connect.d.ts +6 -0
  6. package/dist/lib/accounts/connect.js +70 -0
  7. package/dist/lib/daemon-ticks.js +23 -2
  8. package/dist/lib/feed/activity-stream.d.ts +60 -0
  9. package/dist/lib/feed/activity-stream.js +271 -0
  10. package/dist/lib/feed/activity.d.ts +7 -0
  11. package/dist/lib/feed/activity.js +10 -4
  12. package/dist/lib/feed/watch.d.ts +9 -3
  13. package/dist/lib/feed/watch.js +72 -36
  14. package/dist/lib/fleet-shared-state.d.ts +6 -0
  15. package/dist/lib/session/active.d.ts +38 -4
  16. package/dist/lib/session/active.js +36 -4
  17. package/dist/lib/session/bash-command.js +10 -0
  18. package/dist/lib/session/db.d.ts +47 -2
  19. package/dist/lib/session/db.js +131 -4
  20. package/dist/lib/session/mirror.d.ts +5 -0
  21. package/dist/lib/session/mirror.js +166 -0
  22. package/dist/lib/session/parse.d.ts +49 -0
  23. package/dist/lib/session/parse.js +324 -30
  24. package/dist/lib/session/prompt.d.ts +93 -2
  25. package/dist/lib/session/prompt.js +266 -15
  26. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  27. package/dist/lib/session/remote/peer-stream.js +142 -0
  28. package/dist/lib/session/remote/watch.d.ts +1 -1
  29. package/dist/lib/session/remote/watch.js +41 -42
  30. package/dist/lib/session/session-cache.d.ts +9 -0
  31. package/dist/lib/session/session-cache.js +40 -2
  32. package/dist/lib/session/timeline-pass.d.ts +129 -0
  33. package/dist/lib/session/timeline-pass.js +323 -0
  34. package/dist/lib/session/timeline.d.ts +182 -0
  35. package/dist/lib/session/timeline.js +636 -0
  36. package/dist/lib/session/types.d.ts +155 -1
  37. package/dist/lib/summarizer/pass.d.ts +2 -0
  38. package/dist/lib/summarizer/pass.js +14 -1
  39. package/dist/lib/summarizer/summarize.d.ts +7 -0
  40. package/dist/lib/summarizer/summarize.js +3 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.84
4
+
5
+ - **`agents feed watch` is cheap enough to leave running (PHNX-3939).** The watcher
6
+ cost ~42% of a core steadily on a box with 1,437 activity logs / 64 MB, because
7
+ every 500 ms tick re-read and re-parsed the whole activity corpus and re-read
8
+ every row's block, resolution, and PR status. It now keeps a per-file cursor and
9
+ reads only the bytes appended since the last tick (`ActivityStream`), and
10
+ reconciles attention only when something announced a change — a block or
11
+ resolution written under the feed dir, watched directly — or when the 45 s
12
+ PR-status TTL has expired. Measured on the same box over 5 minutes, steady
13
+ state: `--local` **41.9% → 0.31% CPU** (RSS 313 MB → 140 MB) and the full
14
+ 13-peer fleet fan-out **50.0% → 0.37% CPU** (RSS 606 MB → 143 MB), with an
15
+ identical envelope stream. Source: `cli/src/lib/feed/activity-stream.ts`,
16
+ `cli/src/lib/feed/watch.ts`.
17
+
18
+ - **A fleet peer that cannot be reached no longer gets a fresh `ssh` every few
19
+ seconds (PHNX-3939).** `agents feed watch` and `agents sessions watch` respawned
20
+ each peer's `--local` subscription on a bare 2 s timer with the child's stderr
21
+ discarded, so an offline box burned a ConnectTimeout-plus-2s cycle for the whole
22
+ life of the watcher and reported only `ssh exited 255`. Both fan-outs now share
23
+ one implementation with per-peer exponential backoff (2 s doubling to a 60 s
24
+ cap, reset on a healthy protocol event), the peer's own stderr surfaced as the
25
+ `unavailable` reason, and a peer parked after three consecutive failed spawns
26
+ until the device registry changes or the capped delay elapses. Source:
27
+ `cli/src/lib/session/remote/peer-stream.ts`.
28
+
29
+ - **`agents accounts connect` refuses on a worker before any install or browser login (PHNX-3940).** A worker never runs an interactive OAuth flow (credential-management.md invariant 7). On a non-headed device (`worker` or unmarked), connect throws and does nothing — no slot, no install, no browser. Add the account on a personal/desktop box; workers are provisioned from the durable credential. Source: `cli/src/lib/accounts/connect.ts`.
30
+
31
+ - **Every session row carries the request the agent was given and a timeline of what it did (PHNX-3939).** `agents sessions --active --json`, `sessions watch --json`, `feed watch --json`, history rows and the fleet session mirror gain three optional fields: `request` (the LATEST genuine user turn, tidied — the user's own sentence, with screenshot/clip paths, `@dir` mentions and pasted terminal echo pulled out into `attachments` / `pastedLines`, never rewritten), `timeline` (narration-anchored steps — one per line the agent said, with the tool calls under it folded into per-verb counts, failures, blocked calls and milestones; the last 8 in full plus a counter for everything older), and `files` (the paths the session created / modified / deleted, from the harness's own ledger where it keeps one). The fold is deterministic and needs no model; the optional summarizer now takes the narration as input and stays off by default. Computed by the daemon inside its existing reader-gated 15s tick, reading only the bytes each transcript grew by, and cached in the new stamp-validated `session_timelines` table — zero cost on the request path. Source: `cli/src/lib/session/timeline.ts`, `cli/src/lib/session/timeline-pass.ts`, `cli/src/lib/session/db.ts`.
32
+ - **A `/model` echo or a skill body can no longer become a session's title (PHNX-3939).** `extractSessionTopic` consulted a shorter skip list than `cleanFirstUserMessage`, so harness scaffolding — `<local-command-stdout>`, `Base directory for this skill:`, hook feedback, `<hook_result>`, `<notification>` — became a session's topic and then its displayed name. The two lists are now one, and the row title / `userPromptClean` are derived from the raw latest genuine user turn (with the row's attachments in hand) instead of the already-collapsed topic. Source: `cli/src/lib/session/prompt.ts`, `cli/src/lib/session/active.ts`.
33
+ - **`agents sessions trace <id> --steps` prints the step list the sidebar shows.** The same fold as the session row, as text: `NN +MMs source headline · 6 run · 2 blocked · worktree created`, with a totals footer. The CLI door to the timeline for an agent working without the extension. Source: `cli/src/commands/sessions-trace.ts`.
34
+ - **The parser keeps the per-tool label every harness writes (PHNX-3939).** Claude's Bash `input.description`, Kimi's `tool.call.description`, OpenCode's `state.input.description` / `state.title` and Codex's parsed command now ride the normalized `SessionEvent` as `label`, so a live row's now-line reads as the sentence the model wrote for it instead of a re-derived command summary. Codex additionally gains a reader for its typed `item_completed` stream (`AgentMessage` phase, `parsed_cmd`, `FileChange`, `Extension`, `SubAgentActivity`, `ImageView`, `ContextCompaction`) — kept separate from `parseCodexContent` because the two describe the same turn, and an unknown item type folds to a counted call rather than throwing. Claude permission/hook denials are now distinguished from command failures (`blocked` vs `failed`). Source: `cli/src/lib/session/parse.ts`.
35
+ - **`sessions watch` history rows cover every transcript-writing harness.** `readPreviousSessionsForWatch` queried only claude/codex/muse/opencode, so a Kimi, Grok, Cursor or Droid session never appeared as a Previous row. Source: `cli/src/lib/session/remote/watch.ts`.
36
+ - **The timeline redacts and de-escapes the transcript text it ships.** `projectTimeline` is the single place folded text leaves the fold, so step headlines, the now-line and milestone marks now run through `redactSecrets` (on by default; `sessions trace --no-redact` is the only opt-out) and always through `sanitizeForTerminal`. Without it a Bash call with no `description` fell back to raw command text, so a pasted token rode the session row into the git-tracked `~/.agents/devices/<device>/daemon-state.json`, and a transcript carrying ESC sequences could clear the operator's screen on `sessions trace --steps`. `--steps` now honours the command's own `--redact` default, and `sanitizeEvent` covers the new `label` and `phase` fields. Source: `cli/src/lib/session/timeline.ts`, `cli/src/lib/session/parse.ts`, `cli/src/commands/sessions-trace.ts`.
37
+ - **A 4-16 MiB transcript on a non-resumable harness gets a timeline.** Whole-file eligibility was gated on the per-session allowance, capped at 4 MiB however idle the tick was, while `unavailable` only fired above 16 MiB — so a kimi/grok/gemini/cursor/opencode/droid session in that band got no row at all (not `ready`, not `partial`, not `unavailable`) and was counted `reused`, leaving the daemon log reading healthy. It is now gated on what the tick can actually afford, the byte budget is debited by the bytes each branch really reads (the whole file for a whole-file re-parse, not its growth delta), and a session that genuinely does not fit writes a `partial` row naming the budget. A per-session fold that throws is logged and counted `skipped` instead of swallowed. Source: `cli/src/lib/session/timeline-pass.ts`.
38
+ - **Cursor and Droid keep their per-call label too.** Both write the same human one-liner through the shared Anthropic-message parser — Cursor as `description`, Droid as `summary` — and neither reached `SessionEvent.label`, so a Droid `Execute{command:"ls -la", summary:"List files"}` rendered its now-line as `ls -la` while the equivalent Claude call read `List files`. Source: `cli/src/lib/session/parse.ts`.
39
+ - **A tool cut short with Ctrl-C counts as blocked, not failed.** `toolUseResult.interrupted` joins `toolDenialKind` as an input to `blocked`, so interrupting an agent no longer inflates its failure count. Source: `cli/src/lib/session/parse.ts`.
40
+ - **`now` ships only on the live step, and `ready` is never an empty step list.** `attachTool` sets the now-line on every step as it folds; every finished step kept a stale one, so a consumer rendered a now-line on completed work at ~700 bytes a row. A fold that produced no step now reports its state honestly instead of `ready` with nothing in it. The fleet mirror also carries `mix` and `live` through the consume path, which rebuilt each step from a field list that dropped both while keeping `now`. Source: `cli/src/lib/session/timeline.ts`, `cli/src/lib/session/mirror.ts`.
41
+
3
42
  ## 1.22.83
4
43
 
5
44
  - **The Claude status line shows the registered account NAME (PHNX-3987).** A login named with `agents accounts` (`work`, `dev`, …) now renders that name between the host and the model — `yosemite · work · Fable 5.1 · …` — which is what tells several same-harness logins apart at a glance; an unnamed login keeps the email label. The lookup is the one `agents view` and the device inventories already use, lifted into `findNativeAccountByIdentity` so the three copies share it. Source: `cli/src/lib/claude-statusline.ts`, `cli/src/lib/account-registry.ts`.
@@ -535,7 +535,7 @@ export function registerAccountsCommand(program) {
535
535
  agents accounts connect codex personal
536
536
  agents accounts connect claude work # again → reuses work's home, fails closed on a different identity
537
537
  agents run claude --account work`,
538
- notes: `Each NEW account gets a fresh opaque installation label and its own isolated home, so ten accounts can share the same upstream release while keeping separate logins. Connect installs the current release even when it is already installed under another label, then launches the harness's native login there — the OAuth credential is never copied or fleet-synced. Reconnecting an existing account by name reuses its home and refuses to overwrite a home now signed in as a different identity. Supported for version-scoped, isolable harnesses (${connectSupportedList()}); others fail with a clear reason. Provider API-key/token accounts use 'agents accounts add' instead.`,
538
+ notes: `Each NEW account gets a fresh opaque installation label and its own isolated home, so ten accounts can share the same upstream release while keeping separate logins. Connect installs the current release even when it is already installed under another label, then launches the harness's native login there — the OAuth credential is never copied or fleet-synced. Reconnecting an existing account by name reuses its home and refuses to overwrite a home now signed in as a different identity. Headed devices only: on a worker connect refuses before any slot, install, or browser — add the account on a personal/desktop box and provision workers from the durable credential. Supported for version-scoped, isolable harnesses (${connectSupportedList()}); others fail with a clear reason. Provider API-key/token accounts use 'agents accounts add' instead.`,
539
539
  });
540
540
  accounts.command('add <name>')
541
541
  .description('Add a durable API key, setup token, or bearer token')
@@ -1,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  import type { SessionTrajectory } from '../lib/session/trajectory.js';
3
4
  import type { TrajectoryComparison, TrajectoryDivergence, TrajectorySummary } from '../lib/session/trajectory-compare.js';
4
5
  import type { TrajectoryStep } from '../lib/session/trajectory.js';
@@ -50,6 +51,7 @@ interface TraceOptions {
50
51
  output?: string;
51
52
  open?: boolean;
52
53
  errorsOnly?: boolean;
54
+ steps?: boolean;
53
55
  redact?: boolean;
54
56
  all?: boolean;
55
57
  since?: string;
@@ -73,6 +75,15 @@ export declare function chooseFormat(options: TraceOptions, isTTY: boolean): Ren
73
75
  * clear message. Pure, so the boundaries are unit-tested without the command.
74
76
  */
75
77
  export declare function decideTraceLayout(options: Pick<TraceOptions, 'tree' | 'compare'>, selectorCount: number, resolvedCount: number): 'single' | 'compare' | 'lineage';
78
+ /**
79
+ * Render one session's narration-anchored step list — the same fold the daemon
80
+ * caches and the sidebar renders, computed here from the transcript so the
81
+ * command works for any session, live or closed, cached or not.
82
+ */
83
+ export declare function renderSessionSteps(session: SessionMeta, options?: {
84
+ redact?: boolean;
85
+ knownSecrets?: readonly string[];
86
+ }): string;
76
87
  /** Attach the trace behaviour to a command node (canonical or top-level alias). */
77
88
  export declare function configureTraceCommand(cmd: Command): Command;
78
89
  /** Canonical `agents sessions trace <selectors...>`. */
@@ -23,6 +23,8 @@ import { knownSecretValuesFromEnv } from '../lib/redact.js';
23
23
  import { getCacheDir } from '../lib/state.js';
24
24
  import { discoverSessions } from '../lib/session/discover.js';
25
25
  import { parseSession } from '../lib/session/parse.js';
26
+ import { foldTimeline, projectSessionFiles, projectTimeline } from '../lib/session/timeline.js';
27
+ import { parseTimelineEvents } from '../lib/session/timeline-pass.js';
26
28
  import { buildTrajectory } from '../lib/session/trajectory.js';
27
29
  import { diffTrajectories } from '../lib/session/trajectory-compare.js';
28
30
  import { buildLineage } from '../lib/session/trajectory-lineage.js';
@@ -134,6 +136,53 @@ export function decideTraceLayout(options, selectorCount, resolvedCount) {
134
136
  }
135
137
  return 'single';
136
138
  }
139
+ /** One step's counters, rendered as the sidebar renders them: `6 run · 2 blocked`. */
140
+ function stepDetail(step) {
141
+ const mix = Object.entries(step.mix ?? {})
142
+ .filter(([, count]) => (count ?? 0) > 0)
143
+ .sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
144
+ .map(([verb, count]) => `${count} ${verb}`);
145
+ return [
146
+ ...mix,
147
+ step.failed ? `${step.failed} failed` : '',
148
+ step.blocked ? `${step.blocked} blocked` : '',
149
+ ...(step.marks ?? []),
150
+ ].filter(Boolean).join(' · ');
151
+ }
152
+ /**
153
+ * Render one session's narration-anchored step list — the same fold the daemon
154
+ * caches and the sidebar renders, computed here from the transcript so the
155
+ * command works for any session, live or closed, cached or not.
156
+ */
157
+ export function renderSessionSteps(session, options = {}) {
158
+ const state = foldTimeline(parseTimelineEvents(session.filePath, session.agent), undefined, {});
159
+ const timeline = projectTimeline(state, undefined, state.steps.length, options);
160
+ if (!timeline.steps.length) {
161
+ return `No steps folded for ${session.shortId || session.id}` +
162
+ `${timeline.reason ? ` — ${timeline.reason}` : ''}\n`;
163
+ }
164
+ const startMs = state.firstMs ?? Date.parse(timeline.steps[0].at);
165
+ const lines = timeline.steps.map((step, index) => {
166
+ const offsetS = Math.max(0, Math.round((Date.parse(step.at) - startMs) / 1000));
167
+ const detail = stepDetail(step);
168
+ return [
169
+ String(index + 1).padStart(2),
170
+ `+${offsetS}s`.padStart(7),
171
+ step.source.slice(0, 4).padEnd(4),
172
+ step.text,
173
+ detail ? `· ${detail}` : '',
174
+ ].filter(Boolean).join(' ');
175
+ });
176
+ const files = projectSessionFiles(state);
177
+ const footer = [
178
+ `${timeline.steps.length} steps`,
179
+ `${timeline.tools} tools`,
180
+ timeline.failed ? `${timeline.failed} failed` : '',
181
+ timeline.blocked ? `${timeline.blocked} blocked` : '',
182
+ files ? `${files.total} file${files.total === 1 ? '' : 's'} changed` : '',
183
+ ].filter(Boolean).join(' · ');
184
+ return `${lines.join('\n')}\n\n${footer}\n`;
185
+ }
137
186
  /** Attach the trace behaviour to a command node (canonical or top-level alias). */
138
187
  export function configureTraceCommand(cmd) {
139
188
  cmd
@@ -144,6 +193,7 @@ export function configureTraceCommand(cmd) {
144
193
  .option('-o, --output <path>', 'Write the rendering to a path instead of opening/printing')
145
194
  .option('--no-open', 'Do not open the HTML; print its path instead')
146
195
  .option('--errors-only', 'Collapse the text trajectory to error steps and their neighbours')
196
+ .option('--steps', 'Print the narration-anchored step list the sidebar shows (the same fold as the session row)')
147
197
  .option('--compare', 'Force the compare layout for exactly two selectors (this is also the default for two)')
148
198
  .option('--tree', 'Render the lineage of one session — it and every session it spawned')
149
199
  .option('--no-redact', 'Local-only: skip secret redaction of derived labels (never for a shared file)')
@@ -161,6 +211,9 @@ agents sessions trace a1b2c3d4 --text
161
211
  # Just the failures and their neighbours — for a triaging agent
162
212
  agents sessions trace a1b2c3d4 --text --errors-only
163
213
 
214
+ # What the agent said it was doing, step by step — the sidebar's timeline as text
215
+ agents sessions trace a1b2c3d4 --steps
216
+
164
217
  # The stable JSON envelope for the AGI EXT Fleet panel or a tool
165
218
  agents sessions trace a1b2c3d4 --json
166
219
 
@@ -223,6 +276,9 @@ Three or more selectors, and --tree with more than one selector, fail loud.`,
223
276
  // count — a single content-search selector matching two sessions must NOT
224
277
  // silently become a compare. All the fail-loud boundaries live in the pure
225
278
  // decideTraceLayout so they are unit-tested (see sessions-trace.test.ts).
279
+ if (options.steps && selectors.length > 1) {
280
+ throw new Error('--steps renders one session\'s step list; pass a single selector.');
281
+ }
226
282
  const layout = decideTraceLayout(options, selectors.length, sessions.length);
227
283
  const redact = options.redact !== false;
228
284
  const knownSecrets = redact ? knownSecretValuesFromEnv() : undefined;
@@ -333,6 +389,21 @@ Three or more selectors, and --tree with more than one selector, fail loud.`,
333
389
  }
334
390
  // One selector — the single-session trajectory.
335
391
  const session = sessions[0];
392
+ // --steps is the CLI door to the timeline fold the sidebar renders, so an
393
+ // agent can read what a session DID without the extension (PHNX-3939). It is
394
+ // a different model from the trajectory (narration beats, not tool steps),
395
+ // so it short-circuits before buildTrajectory rather than reshaping it.
396
+ if (options.steps) {
397
+ const out = renderSessionSteps(session, { redact, knownSecrets });
398
+ if (options.output) {
399
+ fs.writeFileSync(options.output, out, { mode: 0o600 });
400
+ process.stderr.write(chalk.green(`Wrote step list to ${options.output}\n`));
401
+ }
402
+ else {
403
+ process.stdout.write(out);
404
+ }
405
+ return;
406
+ }
336
407
  const model = buildOne(session);
337
408
  if (format === 'json') {
338
409
  const out = JSON.stringify(buildTraceEnvelope([model]), null, 2) + '\n';
@@ -18,6 +18,12 @@ export declare function connectSupported(agent: AgentId): boolean;
18
18
  */
19
19
  export declare function connectRefusal(agent: AgentId): string | null;
20
20
  export declare function assertConnectSupported(agent: AgentId): void;
21
+ /**
22
+ * Named reason connect refuses on a non-headed device, or null when this box
23
+ * may run an interactive login. Workers never mint a native OAuth login
24
+ * (credential-management.md invariant 7 + Provisioning model).
25
+ */
26
+ export declare function connectWorkerRefusal(agent: AgentId, name?: string): string | null;
21
27
  export declare function loginInvocation(agent: AgentId): LoginInvocation;
22
28
  /**
23
29
  * Mint a fresh, opaque installation slot. `acct-<hex>` — alnum + hyphen only, so
@@ -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
@@ -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
  /**
@@ -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
+ }