@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
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
+
42
+ ## 1.22.83
43
+
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`.
45
+
46
+ - **`agents accounts rename` / `remove` / `view` honor per-harness account names (PHNX-3988).** Native labels are unique per harness (PHNX-3887), but rename/remove still ran a fleet-wide uniqueness check and resolved a bare name to whichever harness row the store ordered first — so renaming Codex's `cxicloud` to `icloud` failed because Claude already owned `icloud`. Rename now scopes uniqueness to the target row's harness, so one harness can take a name another already uses; a duplicate within the same harness is still refused. Rename, remove, and view accept `<harness>#<name>` and refuse an ambiguous bare name rather than guessing. Source: `cli/src/lib/account-registry.ts`, `cli/src/commands/accounts.ts`.
47
+
48
+ - **Feed activity polling reuses unchanged log tails (PHNX-3939).** The activity reader caches file-version-validated tails and timestamp summaries within a bounded process-local budget. Cursor polls skip unchanged historical events without parsing them again; appends, rewrites, replacements and tail-budget changes invalidate the cached snapshot. Returned events remain independently mutable, with timestamp, filter and limit behavior preserved.
49
+
3
50
  ## 1.22.82
4
51
 
5
52
  - **The Claude status line names the signed-in account before the model (PHNX-3987).** The managed `agents __claude-statusline` render now reads `host · account · model · 5h n% · 7d n% · ◆ reminder`, where the account is the email the running Claude is signed into (plus the org name for a Team/Enterprise seat, the same label `agents view` renders), read from the `.claude.json` of the home Claude is actually running with — the version home under the launch shim, `$HOME` otherwise. A never-signed-in home omits the part. Source: `cli/src/lib/claude-statusline.ts`.
@@ -21,7 +21,7 @@ import { connectRefusal, connectSupported, runConnect } from '../lib/accounts/co
21
21
  import { acquireAuthOperationLock } from '../lib/accounts/auth-operation-lock.js';
22
22
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
23
23
  import { getAccountProvider, listAccountProviders, providerAuthenticatesHarness } from '../lib/account-provider-registry.js';
24
- import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, nativeAccountHome, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
24
+ import { accountBindings, addAccount, addNativeAccount, assertUnambiguousNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, nativeAccountHome, parseAccountSelector, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
25
25
  import { registerMintCommand } from './auth-mint.js';
26
26
  /** Comma-joined list of harnesses `accounts connect` can drive today. */
27
27
  function connectSupportedList() {
@@ -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')
@@ -580,12 +580,21 @@ agents run claude --account work`,
580
580
  console.log(chalk.green(`Updated credential for account '${name}'.`));
581
581
  });
582
582
  });
583
- accounts.command('view <name>').alias('inspect').description('Show safe account metadata, custody, and attachments').option('--json', 'Machine-readable output').action(async (name, o, command) => {
583
+ const viewCmd = accounts.command('view <name>')
584
+ .alias('inspect')
585
+ .description('Show safe account metadata, custody, and attachments. Target may be <harness>#<name> when the name exists for several harnesses')
586
+ .option('--json', 'Machine-readable output')
587
+ .action(async (name, o, command) => {
584
588
  await runAccountsAction(command, async () => {
585
589
  const meta = readMeta();
586
- const unified = findUnifiedAccount(name, meta);
590
+ const selector = parseAccountSelector(name);
591
+ assertUnambiguousNativeAccount(meta, selector.name, selector.agent);
592
+ const unified = findUnifiedAccount(selector.name, meta, undefined, selector.agent);
587
593
  if (!unified)
588
594
  throw new Error(`Unknown account '${name}'.`);
595
+ if (selector.agent && unified.kind === 'native' && unified.agent !== selector.agent) {
596
+ throw new Error(`Unknown ${selector.agent} account '${selector.name}'.`);
597
+ }
589
598
  const account = unified.kind === 'provider'
590
599
  ? { ...publicAccount(inspectAccount(unified.name)), custody: 'agents secrets (policy never)', attached: accountBindings(unified.id, meta) }
591
600
  : { ...unified, custody: `${unified.agent} (not stored by agents-cli)`, attached: accountBindings(unified.id, meta) };
@@ -607,6 +616,11 @@ agents run claude --account work`,
607
616
  console.log(` attached: ${account.attached.length ? account.attached.join(', ') : 'none'}`);
608
617
  });
609
618
  });
619
+ setHelpSections(viewCmd, {
620
+ examples: `agents accounts view work --json
621
+ agents accounts view codex#icloud`,
622
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
623
+ });
610
624
  accounts.command('name <source> <name>')
611
625
  .description('Name a signed-in native installation without copying its OAuth credentials')
612
626
  .action(async (source, name, _o, command) => {
@@ -723,12 +737,25 @@ agents run codex#work`,
723
737
  console.log(chalk.green(`Detached ${name} from ${target}.`));
724
738
  });
725
739
  });
726
- accounts.command('rename <old> <new>').description('Rename an account without changing its stable id').action(async (oldName, newName, _o, command) => {
740
+ const renameCmd = accounts.command('rename <old> <new>')
741
+ .description('Rename an account without changing its stable id. Target may be <harness>#<name> when the name exists for several harnesses')
742
+ .action(async (oldName, newName, _o, command) => {
727
743
  await runAccountsAction(command, () => { renameAccount(oldName, newName); });
728
744
  });
729
- accounts.command('remove <name>').description('Remove an account and its device-local credential').action(async (name, _o, command) => {
745
+ setHelpSections(renameCmd, {
746
+ examples: `agents accounts rename cxicloud icloud
747
+ agents accounts rename codex#icloud cloud`,
748
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
749
+ });
750
+ const removeCmd = accounts.command('remove <name>')
751
+ .description('Remove an account and its device-local credential. Target may be <harness>#<name> when the name exists for several harnesses')
752
+ .action(async (name, _o, command) => {
730
753
  await runAccountsAction(command, () => { removeAccount(name); });
731
754
  });
755
+ setHelpSections(removeCmd, {
756
+ examples: `agents accounts remove claude#icloud`,
757
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
758
+ });
732
759
  accounts.command('set-default <agent> <name>')
733
760
  .description('Use this account for a harness when --account is omitted')
734
761
  .action(async (agentRaw, name, _o, command) => {
@@ -849,6 +876,7 @@ agents accounts name claude@2.1.220 work
849
876
  agents accounts label codex@0.146.0 personal
850
877
  agents accounts attach work claude@2.1.225
851
878
  agents accounts view work --json
879
+ agents accounts view codex#icloud
852
880
  agents accounts switch claude
853
881
  agents accounts switch claude work
854
882
  agents accounts set-default claude work
@@ -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';
@@ -20,7 +20,7 @@ import { listCliStatus } from '../lib/cli-resources.js';
20
20
  import { isCapable } from '../lib/capabilities.js';
21
21
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
22
22
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
23
- import { listNativeAccounts } from '../lib/account-registry.js';
23
+ import { findNativeAccountByIdentity } from '../lib/account-registry.js';
24
24
  import { buildNativeCatalog } from '../lib/account-catalog.js';
25
25
  import { connectSupported } from '../lib/accounts/connect.js';
26
26
  import { readInstallation } from '../lib/installations/store.js';
@@ -67,11 +67,8 @@ export const accountColumnLabel = accountDisplayLabel;
67
67
  */
68
68
  export function namedAccountColumnLabel(agentId, info) {
69
69
  const display = accountColumnLabel(info);
70
- const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
71
- if (!identityKey)
72
- return display;
73
- const saved = listNativeAccounts(readMeta()).find(item => item.agent === agentId && item.identityKey === identityKey);
74
- return saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display;
70
+ const saved = findNativeAccountByIdentity(readMeta(), agentId, info);
71
+ return saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display;
75
72
  }
76
73
  /** Human account identity; release labels belong only in installation diagnostics. */
77
74
  export function nativeAccountViewLabel(row) {
@@ -1,4 +1,4 @@
1
- import type { AgentId, Meta } from './types.js';
1
+ import { type AgentId, type Meta } from './types.js';
2
2
  import { type AccountAuthKind } from './account-provider-registry.js';
3
3
  export interface CredentialAccount {
4
4
  id: string;
@@ -43,6 +43,18 @@ export declare function findAccount(name: string, doc?: AccountRegistryDocument)
43
43
  * accumulate). On an id collision the device slice wins.
44
44
  */
45
45
  export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
46
+ /**
47
+ * The native account registered for one harness login, or null when that login
48
+ * is unnamed. `info` is the login's identity as `getAccountInfo` /
49
+ * `readClaudeHomeConfig` report it; the match key is the same value every
50
+ * catalog and inventory groups a home on — the stable `accountKey`, else the
51
+ * lowercased email. The one lookup behind `agents view`, the device
52
+ * inventories, and the Claude status line.
53
+ */
54
+ export declare function findNativeAccountByIdentity(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agent: AgentId, info: {
55
+ accountKey?: string | null;
56
+ email?: string | null;
57
+ } | null | undefined): NativeAccount | null;
46
58
  /**
47
59
  * Resolve one account by name or id across both stores, native first.
48
60
  *
@@ -55,6 +67,23 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
55
67
  * able to omit it.
56
68
  */
57
69
  export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
70
+ /**
71
+ * Split a management selector into its harness and name. `<harness>#<name>`
72
+ * pins the harness; a bare name (or row id) carries none. Native names are
73
+ * unique per harness (PHNX-3887), so a bare name alone can legitimately match a
74
+ * claude row AND a codex row — the selector is how rename/remove say which.
75
+ */
76
+ export declare function parseAccountSelector(input: string): {
77
+ agent?: AgentId;
78
+ name: string;
79
+ };
80
+ /**
81
+ * Refuse a bare name that native rows in several harnesses share. A
82
+ * harness-qualified selector (`agent` set) never hits this — uniqueness is
83
+ * per harness. Ids are unique so they never collide either. Shared by
84
+ * rename/remove/view so the message stays one string.
85
+ */
86
+ export declare function assertUnambiguousNativeAccount(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, name: string, agent?: AgentId): void;
58
87
  /**
59
88
  * Validate a native account NAME (charset + per-harness uniqueness) WITHOUT a
60
89
  * known identity — the pre-flight `agents accounts connect` runs before it
@@ -120,8 +149,16 @@ export interface AddAccountOptions {
120
149
  }
121
150
  export declare function addAccount(name: string, provider: string, auth: AccountAuthKind, secret: string, base?: string, opts?: AddAccountOptions): CredentialAccount;
122
151
  export declare function setAccountSecret(name: string, secret: string, base?: string): void;
123
- export declare function renameAccount(oldName: string, newName: string, base?: string): void;
124
- export declare function removeAccount(name: string, base?: string): void;
152
+ /**
153
+ * `oldSelector` is a bare name, a row id, or `<harness>#<name>`. A native row
154
+ * carries its harness, so the new name only has to be free within THAT harness
155
+ * (PHNX-3887 / PHNX-3988): renaming codex's `cxicloud` to `icloud` is fine
156
+ * while claude's `icloud` stays untouched. Provider accounts have no harness
157
+ * and stay globally unique.
158
+ */
159
+ export declare function renameAccount(oldSelector: string, newName: string, base?: string): void;
160
+ /** `selector` is a bare name, a row id, or `<harness>#<name>` (see {@link renameAccount}). */
161
+ export declare function removeAccount(selector: string, base?: string): void;
125
162
  export declare function inspectAccount(name: string, base?: string): CredentialAccount & {
126
163
  secretPresent: boolean;
127
164
  policy: 'never';
@@ -23,6 +23,7 @@ import * as yaml from 'yaml';
23
23
  import chalk from 'chalk';
24
24
  import { atomicWriteFileSync } from './fs-atomic.js';
25
25
  import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
26
+ import { isAgentId } from './types.js';
26
27
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
27
28
  import { bundleExists, deleteBundle, listBundles, readAndResolveBundleEnv, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
28
29
  import { getAccountProvider } from './account-provider-registry.js';
@@ -148,6 +149,20 @@ export function listNativeAccounts(meta) {
148
149
  const merged = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
149
150
  return Object.values(merged).map(account => ({ ...account, kind: 'native' }));
150
151
  }
152
+ /**
153
+ * The native account registered for one harness login, or null when that login
154
+ * is unnamed. `info` is the login's identity as `getAccountInfo` /
155
+ * `readClaudeHomeConfig` report it; the match key is the same value every
156
+ * catalog and inventory groups a home on — the stable `accountKey`, else the
157
+ * lowercased email. The one lookup behind `agents view`, the device
158
+ * inventories, and the Claude status line.
159
+ */
160
+ export function findNativeAccountByIdentity(meta, agent, info) {
161
+ const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
162
+ if (!identityKey)
163
+ return null;
164
+ return listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey) ?? null;
165
+ }
151
166
  /**
152
167
  * Resolve one account by name or id across both stores, native first.
153
168
  *
@@ -168,8 +183,9 @@ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
168
183
  // merged store happened to order first, so `agents run claude#<email>` died with
169
184
  // "Account 'personal' is a codex login and cannot authenticate the claude harness"
170
185
  // while that identity's own claude login sat right there. Prefer the harness being
171
- // launched; fall back to the first match so a management lookup with no harness in
172
- // hand (rename/remove/view) behaves exactly as before.
186
+ // launched; fall back to the first match when the caller has no harness in hand.
187
+ // rename/remove/view refuse an ambiguous *name* via assertUnambiguousNativeAccount
188
+ // before they get here — this fallback is for identityLabel collisions.
173
189
  const native = matches.find(account => account.agent === preferAgent) ?? matches[0];
174
190
  if (native)
175
191
  return native;
@@ -179,10 +195,46 @@ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
179
195
  function nativeIdentityRows(meta, agent, identityKey) {
180
196
  return listNativeAccounts(meta).filter(account => account.agent === agent && account.identityKey === identityKey);
181
197
  }
198
+ /**
199
+ * Split a management selector into its harness and name. `<harness>#<name>`
200
+ * pins the harness; a bare name (or row id) carries none. Native names are
201
+ * unique per harness (PHNX-3887), so a bare name alone can legitimately match a
202
+ * claude row AND a codex row — the selector is how rename/remove say which.
203
+ */
204
+ export function parseAccountSelector(input) {
205
+ const hash = input.indexOf('#');
206
+ if (hash < 0)
207
+ return { name: input };
208
+ const agentRaw = input.slice(0, hash);
209
+ const name = input.slice(hash + 1).trim();
210
+ if (!isAgentId(agentRaw))
211
+ throw new Error(`Unknown agent '${agentRaw}'.`);
212
+ if (!name)
213
+ throw new Error('Select an account after #.');
214
+ return { agent: agentRaw, name };
215
+ }
216
+ /**
217
+ * Refuse a bare name that native rows in several harnesses share. A
218
+ * harness-qualified selector (`agent` set) never hits this — uniqueness is
219
+ * per harness. Ids are unique so they never collide either. Shared by
220
+ * rename/remove/view so the message stays one string.
221
+ */
222
+ export function assertUnambiguousNativeAccount(meta, name, agent) {
223
+ const matches = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent) && (account.id === name || account.name === name));
224
+ const harnesses = [...new Set(matches.map(account => account.agent))].sort();
225
+ if (harnesses.length > 1) {
226
+ throw new Error(`Account '${name}' exists for several harnesses (${harnesses.join(', ')}). `
227
+ + `Pick one with <harness>#${name}, e.g. ${harnesses[0]}#${name}.`);
228
+ }
229
+ }
182
230
  /** Every row (central + this box's device store) for the identity that `name`
183
- * (id or account name) resolves to. */
184
- function nativeRowsForNameOrId(meta, name) {
185
- const found = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
231
+ * (id or account name) resolves to. With `agent` set only that harness's rows
232
+ * are considered; without it, a name owned by rows in several harnesses is
233
+ * refused rather than resolved to whichever the store ordered first. */
234
+ function nativeRowsForNameOrId(meta, name, agent) {
235
+ assertUnambiguousNativeAccount(meta, name, agent);
236
+ const matches = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent) && (account.id === name || account.name === name));
237
+ const found = matches[0];
186
238
  if (!found)
187
239
  return [];
188
240
  return nativeIdentityRows(meta, found.agent, found.identityKey);
@@ -197,9 +249,9 @@ function nativeRowsForNameOrId(meta, name) {
197
249
  * actually ambiguous at the point of use: the selector is `<harness>#<label>`,
198
250
  * and `findUnifiedAccount` already disambiguates via `preferAgent`.
199
251
  *
200
- * Pass `agent` to scope the check to that harness. Omit it for management
201
- * lookups with no harness in hand (rename/remove), which keep the old
202
- * fleet-wide check so a rename cannot collide with an unrelated harness's row.
252
+ * Pass `agent` to scope the check to that harness. Every native path has one
253
+ * in hand — connect/label from the caller, rename from the row being renamed —
254
+ * so the un-scoped form is only for provider accounts.
203
255
  *
204
256
  * Provider (non-native) accounts stay globally unique — they are selected by
205
257
  * bare name via `--account`, with no harness to scope them by.
@@ -493,21 +545,32 @@ export function setAccountSecret(name, secret, base = getUserAgentsDir()) {
493
545
  bundle.created_at = readBundle(account.name).created_at; // rotate the secret, keep the bundle's birth time
494
546
  writeBundleWithItems(bundle, items);
495
547
  }
496
- export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
548
+ /**
549
+ * `oldSelector` is a bare name, a row id, or `<harness>#<name>`. A native row
550
+ * carries its harness, so the new name only has to be free within THAT harness
551
+ * (PHNX-3887 / PHNX-3988): renaming codex's `cxicloud` to `icloud` is fine
552
+ * while claude's `icloud` stays untouched. Provider accounts have no harness
553
+ * and stay globally unique.
554
+ */
555
+ export function renameAccount(oldSelector, newName, base = getUserAgentsDir()) {
497
556
  assertName(newName);
498
- const doc = readAccountRegistry(base);
499
557
  const meta = readMeta();
500
- const rows = nativeRowsForNameOrId(meta, oldName);
558
+ const selector = parseAccountSelector(oldSelector);
559
+ const rows = nativeRowsForNameOrId(meta, selector.name, selector.agent);
501
560
  if (rows.length) {
502
- assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
561
+ assertUniqueUnifiedName(newName, meta, undefined, new Set(rows.map(account => account.id)), rows[0].agent);
503
562
  // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315)
504
563
  // and any per-harness default that points to the old name or row ids.
505
564
  const rowScope = rows[0].scope;
506
- const idsToRename = new Set([oldName, ...rows.map(row => row.id)]);
565
+ const renamedAgent = rows[0].agent;
566
+ const renamedIds = new Set(rows.map(row => row.id));
507
567
  updateMeta(current => {
508
568
  const defaults = { ...current.accounts?.defaults };
509
569
  for (const [agent, value] of Object.entries(defaults)) {
510
- if (idsToRename.has(value))
570
+ // Ids are unique so an id match may rewrite any harness's default. A
571
+ // bare-name match must only rewrite THIS harness — two harnesses may
572
+ // legitimately share the same native name (PHNX-3988).
573
+ if (renamedIds.has(value) || (agent === renamedAgent && value === selector.name))
511
574
  defaults[agent] = newName;
512
575
  }
513
576
  const next = { ...current, accounts: { ...current.accounts, defaults } };
@@ -527,9 +590,12 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
527
590
  });
528
591
  return;
529
592
  }
530
- const account = findAccount(oldName, doc);
593
+ if (selector.agent)
594
+ throw new Error(`Unknown ${selector.agent} account '${selector.name}'.`);
595
+ const doc = readAccountRegistry(base);
596
+ const account = findAccount(selector.name, doc);
531
597
  if (!account)
532
- throw new Error(`Unknown account '${oldName}'.`);
598
+ throw new Error(`Unknown account '${selector.name}'.`);
533
599
  assertUniqueUnifiedName(newName, meta, doc);
534
600
  const idsToRename = new Set([account.name, account.id]);
535
601
  updateMeta(current => {
@@ -543,9 +609,11 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
543
609
  renameBundle(account.name, newName); // moves metadata + secret, preserves ACCOUNT_ID
544
610
  renameProfileConsumers(account.name, newName, base);
545
611
  }
546
- export function removeAccount(name, base = getUserAgentsDir()) {
612
+ /** `selector` is a bare name, a row id, or `<harness>#<name>` (see {@link renameAccount}). */
613
+ export function removeAccount(selector, base = getUserAgentsDir()) {
547
614
  const meta = readMeta();
548
- const rows = nativeRowsForNameOrId(meta, name);
615
+ const parsed = parseAccountSelector(selector);
616
+ const rows = nativeRowsForNameOrId(meta, parsed.name, parsed.agent);
549
617
  if (rows.length) {
550
618
  const bindings = [...new Set(rows.flatMap(row => accountBindings(row.id, meta)))].sort();
551
619
  if (bindings.length)
@@ -571,6 +639,9 @@ export function removeAccount(name, base = getUserAgentsDir()) {
571
639
  });
572
640
  return;
573
641
  }
642
+ if (parsed.agent)
643
+ throw new Error(`Unknown ${parsed.agent} account '${parsed.name}'.`);
644
+ const name = parsed.name;
574
645
  const account = findAccount(name, readAccountRegistry(base));
575
646
  if (!account)
576
647
  throw new Error(`Unknown account '${name}'.`);
@@ -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