@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.
- package/CHANGELOG.md +39 -0
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/daemon-ticks.js +23 -2
- 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 +10 -4
- 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
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
|
package/dist/lib/daemon-ticks.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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
|
+
}
|