@phnx-labs/agents-cli 1.20.89 → 1.20.90

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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,245 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.90
4
+
5
+ - **`agents sessions --active` now shows one row per agent, not one per directory.**
6
+ A live tmux agent pane whose durable identity records were missing (the common case
7
+ once meta/pid-registry entries age out) was dropped, then re-surfaced by the ps-scan
8
+ under the newest transcript in its cwd — so many distinct sessions collapsed onto one
9
+ stranger's id with an inflated `×N` badge, and `agents sessions focus <id>` could not
10
+ find them. The scanner now recovers the session id straight from the `ag-<agent>-<shortid>`
11
+ tmux pane name (resolved to the full UUID via the short-id index in one batched query),
12
+ and refuses to borrow a co-located sibling's transcript when no id is known — so every
13
+ live session surfaces as its own row and is focus-able again. Also adds a `runTmux`
14
+ timeout so a wedged tmux server can't hang the scan. Source: `apps/cli/src/lib/session/active.ts`.
15
+
16
+ - **`agents view` now shows live usage bars for Antigravity.** The `agy` account
17
+ row renders one bar per model quota bucket (`3.1P: ███░░ 42% (1d)` style),
18
+ sourced from the same Google Code Assist `:retrieveUserQuota` endpoint `agy`
19
+ itself talks to. Auth reuses the stored `agy` OAuth credential (macOS Keychain
20
+ item `gemini`/`antigravity`, Linux Secret Service, or the
21
+ `~/.gemini/antigravity-cli/antigravity-oauth-token` file fallback), refreshing
22
+ the access token in memory when expired — safe from a read path because
23
+ Google's refresh tokens are non-rotating, and never written back to the
24
+ keychain. Each per-model bucket also flows into the throttle badge, run
25
+ rotation eligibility, and `agents view --json` (whose usage windows now carry
26
+ a `label` so same-keyed per-model bars are distinguishable). Source:
27
+ `apps/cli/src/lib/usage.ts`, `apps/cli/src/lib/agents.ts`,
28
+ `apps/cli/src/commands/view.ts`.
29
+
30
+ - **A custom harness is now its own agent type in `agents view`.** A harness created
31
+ with `agents harness add` (or `agents profiles add`) used to render as an indented
32
+ `profile` row under whichever host CLI executes it. It now gets its own block beside
33
+ Claude and Codex — a bold name header, then one row carrying the pinned model, the
34
+ account/auth state, and `via <host> <version>` naming the native harness underneath.
35
+ That matches how it is already launched: `agents run <name>` treats a custom harness
36
+ exactly like a native agent id. A harness whose host CLI has no install is flagged
37
+ `(host <id> not installed)` rather than listed as runnable, and the separate
38
+ "Profile-only Agents" section is gone — those harnesses now render in the main list
39
+ like every other one. Source: `apps/cli/src/commands/view.ts`.
40
+ - **`agents view <harness>` describes a custom harness** — host, model, provider, auth,
41
+ fork lineage, YAML path — instead of failing with "unknown agent";
42
+ `agents view <harness> --json` emits its summary. Source:
43
+ `apps/cli/src/commands/harness.ts` (`renderHarnessDetail`).
44
+ - **New `agents harness fork <source> <name>`.** One verb over both starting points:
45
+ fork a native harness (`agents harness fork opencode deepseek --model
46
+ deepseek/deepseek-v4-flash-0731 --auth-provider openrouter`) or copy a custom one you
47
+ already tuned and change only what you name (`agents harness fork deepseek
48
+ deepseek-chat --model deepseek/deepseek-chat-v3`). Forking a custom harness is a full
49
+ copy — env, endpoint, auth binding, `fallback_model`, host version pin — so the two
50
+ diverge and deleting the source never affects the fork; forking a native harness
51
+ requires `--model` because there is no model to inherit. Flags: `--model`,
52
+ `--base-url`, `--auth-provider`, `--version`, `--label`, `--description`,
53
+ `--key-stdin`, `--force`. Source: `apps/cli/src/lib/profiles.ts` (`forkProfile`).
54
+ - **Profile YAML gains optional `label:` and `forkedFrom:`.** `label` sets the name
55
+ `agents view` prints for the harness (defaults to the file name); `forkedFrom` records
56
+ the parent as display-only lineage. Existing profiles keep working untouched. Source:
57
+ `apps/cli/src/lib/profiles.ts`.
58
+ - **Breaking (`--json`):** in `agents view <agent> --json`, the per-agent `profiles` key
59
+ is now `harnesses`, and each entry carries new `label`, `hostVersion`, `description`,
60
+ and `forkedFrom` fields alongside the existing ones. Source:
61
+ `apps/cli/src/commands/view.ts` (`ViewJsonAgent`).
62
+
63
+ - **Menu bar ACTIVE: project accordion + session detail submenu.** Projects are
64
+ collapsed by default as a status strip (`▶ agents-cli ●8 ◐1 zion`); click
65
+ `▶`/`▼` to fold agents open under the project (idle-row caps removed — collapse
66
+ is the wall protection). Focusing an agent opens a side submenu with linkable
67
+ detail (work title URL, cwd, Linear ticket, GitHub PR, duration, copy session
68
+ id) from the warm `sessions --active` cache. Accordion reopen rebuilds from
69
+ cache only (no teams walk / no CLI schedule). Local/remote uses the same host
70
+ normalize as CLI `machineId()` so local rows are not mislabeled remote. Source:
71
+ `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`,
72
+ `LocalState.swift`, `Models.swift`.
73
+
74
+ - **An offloaded editor tab no longer displays another session's id.** A Factory
75
+ tab launched with `agents run --host <device>` has no local agent process, but
76
+ the extension still resolved its "live" session id by reading the SessionStart
77
+ hook's `~/.agents/.cache/state/sessions/<pid>.json` for the local pid tree —
78
+ the pid of the ssh client. Those files are keyed by pid alone and are only
79
+ pruned when the pid is dead, so once the OS recycled a pid the tab adopted
80
+ whatever session had last held it: one remote tab showed the id and version of
81
+ an unrelated synthetic run from 20 days earlier while `/status` inside it
82
+ reported the truth. An offloaded tab now takes its identity from the device
83
+ instead of local disk, and a local tab rejects any state record whose
84
+ SessionStart timestamp predates the tab itself.
85
+ - **`AGENT_TERMINAL_ID` now rides the SSH hop.** `agents run --host` forwarded
86
+ actor provenance but not the launching tab's terminal id, so the remote pid
87
+ registry recorded no terminal — leaving `agents sessions --active --host
88
+ <device>` unable to answer "which session is this tab running?" once the agent
89
+ moved on (a `/clear`, or an exit and rerun in the same tab).
90
+ - **`agents sessions --active --json` now carries `terminalId`.** The pid registry
91
+ has always recorded it; the emitted row dropped it, so no consumer could join a
92
+ live session back to the editor tab that launched it.
93
+
94
+ - **Balanced routing no longer launches into an account it only *thinks* has
95
+ headroom.** Account usage is cached per machine under stale-while-revalidate:
96
+ a snapshot up to 24h old was served instantly, and the background refresh that
97
+ should have corrected it lands after the pick is already made. On a box whose
98
+ refresh is failing that state is permanent — measured on `yosemite-s1`, every
99
+ Claude snapshot sat 26 hours to 2.7 days old, so balanced read
100
+ `muqsit@getrush.ai` as 48% used and launched into it while the account was at
101
+ its weekly cap; the session answered "You've hit your weekly limit" on its
102
+ first turn. Routing now caps how stale a snapshot may be when it is about to
103
+ decide (5 minutes), blocking on one bounded, parallel live read past that — and
104
+ no read at all inside the existing 2-minute fresh window, which back-to-back
105
+ launches hit. Display paths (`agents view`) keep the full 24h window and stay
106
+ off the network.
107
+ - **A pick made on unconfirmed data says so.** When no account on the machine
108
+ could be refreshed, routing still launches — a broken refresh must not make a
109
+ box unusable — but the banner now reads `… (2 of 5 healthy, usage unverified —
110
+ no account could be refreshed)` instead of presenting a guess as a fact. An
111
+ account with a verified snapshot always wins over one with a stale snapshot,
112
+ even when the stale number looks emptier. This applies to `--strategy
113
+ available` as well as `balanced` — both route on the same cache, and
114
+ `available`'s headroom sort was inverted by a stale number in exactly the same
115
+ way. An explicit version preference is an instruction, not a ranking signal, so
116
+ it still wins.
117
+ - **The mid-run failover chain is unchanged.** Declining to *pick* an account on
118
+ unconfirmed data and declining to *fail over to* it after the primary already
119
+ hit a 429 are different risks — by then the alternative is not launching at
120
+ all. Every eligible account stays in the failover chain; only the initial pick
121
+ prefers verified ones.
122
+
123
+ - **`agents routines list` no longer reports another device's routine as failed.** Run
124
+ records are written into the runs dir of whichever machine fired the routine and carry
125
+ no device attribution, but the listing resolved Last Status from any local record and
126
+ rendered it even on rows for routines pinned elsewhere. A routine re-pinned to another
127
+ device therefore kept reporting the old machine's leftover records forever — on zion,
128
+ `security-sweep`, `review-open-prs` and `hetzner-lease-gc` all read `failed` from late
129
+ July while `yosemite-s0`/`s1`, the devices that actually fire them, had completed them
130
+ that morning. The macOS menu bar reads this JSON, so it painted a column of red `exit 1`
131
+ rows for routines that were green. Last Status is now scoped to the device that owns the
132
+ run: a routine this device does not fire shows `-`, and `--json` returns `null` for
133
+ `lastStatus`, `exitCode`, `failureReason`, `lastRunStartedAt` and `lastRunCompletedAt`
134
+ (`runsHere: false` already says why). A routine pinned to several devices renders one row
135
+ per device but carries a status only on its **This machine** row. Read a peer's status
136
+ with `agents routines list --device <name>`; the local history is untouched and still
137
+ readable via `agents routines runs <name>`. Source: `apps/cli/src/commands/routines.ts`
138
+ (`localLatestRun`, `groupRoutineJobsByDevice`), `apps/cli/docs/03-routines.md`.
139
+
140
+ - **`agents watchdog` now tracks per-session presence (RUSH-2007 Layer C).** Each
141
+ tick reconciles a per-session presence record — `{location, device, transport,
142
+ lastSeen, status}` at `~/.agents/.cache/state/watchdog/presence.json` — from the
143
+ tick's active scan, deriving `connected` / `disconnected` by diffing consecutive
144
+ ticks. A session that was tracked but is now absent (its SSH link dropped or the
145
+ peer went unreachable) flips to `disconnected`, and the flip is surfaced in
146
+ `agents watchdog --json` under `presence.transitions` — an interactive drop as a
147
+ `reconnect-nudge` candidate, a headless remote as `keep-alive`. Folded into the
148
+ existing tick (no revived daemon, no extra SSH fan-out); additive and does not
149
+ change the tick's nudge decisions. Source:
150
+ `apps/cli/src/lib/session/presence.ts`, `apps/cli/src/lib/watchdog/runner.ts`.
151
+
152
+ - **`agents setup secrets --policy hold` no longer fails, and `agents secrets
153
+ status` stops naming the retired `daily` policy.** The 1.20.79 `daily` → `hold`
154
+ rename swept the help, docs, and the `secrets list` POLICY column, but two
155
+ surfaces were never migrated. The worse one was functional: the onboarding
156
+ wizard carried its own copy of the policy vocabulary, so
157
+ `agents setup secrets --policy hold` — the canonical name every other secrets
158
+ command prints — exited with `Invalid --policy 'hold'. Use daily, always, or
159
+ never.`, and its interactive prompt still offered `daily` as the default
160
+ choice. It now shares `parsePolicyOpt` with `agents secrets policy`, so the two
161
+ commands can't disagree about what a policy is called; `daily`/`session` stay
162
+ accepted as aliases and the wizard's default is unchanged (the hold tier). The
163
+ second was cosmetic: `agents secrets status` printed "a daily bundle prompts
164
+ once…" and "the next read of each daily bundle…" — the one command a user runs
165
+ to answer *why did it prompt again*, naming a policy its sibling commands no
166
+ longer emit. Both lines now say `hold` and are pure values pinned by tests, so
167
+ the vocabulary can't drift again. Source:
168
+ `apps/cli/src/commands/setup-secrets.ts`, `apps/cli/src/commands/secrets.ts`.
169
+
170
+ - **Favorite sessions from the browser.** `*` stars the highlighted session in
171
+ `agents sessions` and `f` filters the list to the starred ones; outside a TTY,
172
+ `agents sessions favorite <id>` (`--remove` / `--list` / `--json`) and
173
+ `agents sessions --favorites` do the same. Stars live in
174
+ `~/.agents/.history/favorites.json` keyed by session id, so they survive a reindex
175
+ of the session cache. They are per-machine — session sync carries transcripts, not
176
+ this file. Source:
177
+ `apps/cli/src/lib/session/favorites.ts`, `apps/cli/src/commands/sessions-favorite.ts`.
178
+ - **Detect sessions that lost their host — two new statuses, `crashed` and `orphaned`.**
179
+ A session whose editor window or connection went down hard used to just VANISH from
180
+ `agents sessions --active` (its dead-pid registry entry was filtered out), and one
181
+ still running in tmux with nobody attached reported a plain `idle`. Both now say so:
182
+ `✗ crashed` when the host window stopped republishing and the agent died with it,
183
+ `◍ orphan` when the agent is alive with zero clients attached. Derived from tmux's
184
+ `#{session_attached}` and the IDE window's registry heartbeat — never from a
185
+ deliberate `agents sessions detach`, and never over a session that is still working.
186
+ Source: `apps/cli/src/lib/session/host-link.ts`, `apps/cli/src/lib/session/active.ts`.
187
+ - **`agents sessions --active --favorites` now actually filters.** The flag was wired
188
+ into the interactive browser only, so every path that skips it — `--json`,
189
+ `--waiting`, a pipe, a multi-host scope, an SSH-fanout peer — silently returned the
190
+ whole fleet. Source: `apps/cli/src/commands/sessions.ts`.
191
+ - **`agents sessions --active --waiting` no longer counts a dead session.** `activity`
192
+ is not rewritten when a session dies, so one that crashed mid-question reported "needs
193
+ your input" forever — what it needs is a relaunch. Source:
194
+ `apps/cli/src/commands/sessions.ts`.
195
+
196
+ - **Resolve historical sessions safely across the fleet (#1757).** `agents sessions --resolve <full-id|prefix|keywords> --json` uses a versioned safe peer protocol, returns only resolver metadata, reports every full-ID candidate on ambiguity, treats synced copies as one match, and exits 2 without deciding when a peer fails, returns malformed output, or runs an older CLI. Source: `apps/cli/src/commands/sessions.ts`.
197
+
198
+ - **A rate-limited usage endpoint is now backed off instead of hammered.** The
199
+ daemon warms auth-health every 3 minutes and probes *every installed version
200
+ home* in one parallel batch, so a machine with five Claude accounts sent five
201
+ concurrent requests to `api.anthropic.com/api/oauth/usage` every three minutes
202
+ — roughly 100/hour — before the usage refresh added its own. Nothing read
203
+ `Retry-After`. Measured on `yosemite-s1`: the endpoint answered
204
+ `429 rate_limit_error` with `retry-after: 2678` (about 45 minutes) for every
205
+ account while the credentials themselves read healthy, and the next tick fired
206
+ three minutes later, deep inside the penalty window, re-arming it. The box
207
+ never recovered, every usage read failed, and its cache froze — the
208
+ permanently-stale state balanced routing was already having to defend against.
209
+ - **A 429 now records its deadline and every read honours it.** Usage fetches and
210
+ health probes for that provider short-circuit until the window passes — no
211
+ request, no renewed penalty — and report
212
+ `Claude rate-limited this machine — not retrying for 45 minutes.` The state is
213
+ on disk, because the callers are separate processes: the long-lived daemon and
214
+ every one-shot `agents view` / `agents run` — one empty file per penalty under
215
+ `~/.agents/.cache/usage-backoff/`, named `<agent>.<deadline>`, so two
216
+ processes recording the same provider at once cannot displace each other and a
217
+ read takes the furthest deadline. A server delay is capped at an hour, and a
218
+ missing or unparseable `Retry-After` still backs off.
219
+
220
+ - **A usage read that fails now says so, instead of returning a silent null.**
221
+ Four branches in every networked usage fetch — Claude, Kimi, Droid and
222
+ Cursor — returned `{ snapshot: null, error: null }`: no readable credential, a
223
+ locally-expired one, a rejected request, and a request that threw (timeout,
224
+ DNS/TLS, an unparseable payload). The caller could not tell any of them apart
225
+ from a healthy read, so it fell
226
+ back to whatever the stale-while-revalidate cache held and drew those bars as
227
+ fact. Measured on `yosemite-s1`: every Claude account's stored access token had
228
+ expired (one of them eleven days earlier), so no read could succeed, and
229
+ `agents view claude --refresh` printed a full, healthy-looking table twice
230
+ while writing nothing to the cache. A usage read never refreshes a token
231
+ (RUSH-1822), so an expired credential does not heal on its own — the account
232
+ stays unreadable until that agent actually runs. A rate-limited endpoint (429)
233
+ now reads differently from a rejected credential (401), because re-authing
234
+ fixes one and not the other.
235
+ - **`agents view` marks bars the live read could not confirm.** A row whose
236
+ snapshot came from the cache after a failed live read renders the reading plus
237
+ `unverified`, rather than looking identical to a confirmed one. The number
238
+ still shows — it is the last thing we saw — but it no longer reads as current.
239
+ - **`agents view --refresh` reports what it could not refresh.** It now lists
240
+ each account it failed to reach and why, instead of rendering a table that
241
+ looks fully refreshed regardless.
242
+
3
243
  ## 1.20.89
4
244
 
5
245
  - **Webhook handler layer for one-off agent/workflow/command/routine triggers.**
package/README.md CHANGED
@@ -265,6 +265,8 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
265
265
  |---|---|---|
266
266
  | `s` | search text | `--query` / positional |
267
267
  | `r` | running only | `--active` |
268
+ | `f` | favorites only | `--favorites` |
269
+ | `*` | star / unstar the highlighted session | `agents sessions favorite <id>` |
268
270
  | `c` | team sessions | `--teams` |
269
271
  | `a` | agent (cycles) | `-a` |
270
272
  | `d` | device (cycles) | `--device` |
@@ -274,6 +276,10 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
274
276
  | `⏎` | resume / attach | `resume` / `focus` |
275
277
  | `y` | copy the equivalent command | `--print-cmd` |
276
278
 
279
+ **Star the sessions you keep coming back to.** `*` marks the highlighted row (a `★` shows in the listing), `f` narrows to the starred ones, and `agents sessions favorite <id>` / `--favorites` do the same outside a TTY. Stars live in `~/.agents/.history/favorites.json` keyed by session id, so they survive a reindex of the session cache. They're per-machine — session sync carries transcripts, not this file.
280
+
281
+ **A session that lost its host says so.** When an editor window or an SSH connection goes down hard, the agent it owned used to simply disappear from `--active`; when an agent outlived its window in tmux, it reported a plain `idle`. Both now carry their own status: `✗ crashed` (the host went down and took the agent with it) and `◍ orphan` (still alive, but no client is attached — nothing is showing it). Read from tmux's attached-client count and the editor window's registry heartbeat, so a deliberate `agents sessions detach` is never mistaken for one, and a session that is still *working* headlessly is left alone.
282
+
277
283
  Filters **stack** (they AND together), the active set shows in the header, and the highlighted row **previews below by default** (`tab` hides it) — prompt, activity, last response, plus a links line where the worked-on ticket and the PR the session opened are **clickable** (OSC 8 hyperlinks: the ticket jumps to Linear, the `PR#` to GitHub, in terminals that support them). The Linear workspace is resolved from `LINEAR_WORKSPACE` or the linear-cli config, so tickets stay plain text when it's unknown. Because every hotkey has a flag, the view you build by hand is a real command: press `y` (or run `--print-cmd`) to get the exact `ag sessions …` line — explore interactively, hand the line to an agent. Piped output, `--json`, or `--no-interactive` keep the plain listing for scripts. Peek without opening the pager with `agents sessions <id> --preview`.
278
284
 
279
285
  | before — preview hidden | after — preview open + clickable links |
package/dist/bin/agents CHANGED
Binary file
@@ -59,7 +59,13 @@ function formatRotationBanner(result, verb = 'balanced') {
59
59
  const { picked, healthy, excluded } = result;
60
60
  const label = picked.email ? `${picked.email} · ${picked.agent}@${picked.version}` : `${picked.agent}@${picked.version}`;
61
61
  const ratio = `${healthy.length} of ${healthy.length + excluded.length} healthy`;
62
- return `[agents] ${verb} picked ${label} (${ratio})`;
62
+ // Say it when the pick was a guess. A machine whose usage refresh is failing
63
+ // reports old percentages with total confidence, so a silent banner reads
64
+ // identical whether the router knew the account had headroom or merely hoped
65
+ // so — and the operator only finds out when the agent answers "you've hit
66
+ // your weekly limit".
67
+ const caveat = result.usageUnverified ? ', usage unverified — no account could be refreshed' : '';
68
+ return `[agents] ${verb} picked ${label} (${ratio}${caveat})`;
63
69
  }
64
70
  /**
65
71
  * Whether `cwd` is inside a git work tree.
@@ -11,4 +11,31 @@
11
11
  * native harness registry. The `agents profiles` tree stays unchanged.
12
12
  */
13
13
  import type { Command } from 'commander';
14
+ import { type Profile } from '../lib/profiles.js';
15
+ /**
16
+ * Print one custom harness. Shared by `agents harness view <name>` and by
17
+ * `agents view <name>` — a custom harness resolves as an agent type there, so
18
+ * both entry points must describe it identically.
19
+ */
20
+ export declare function renderHarnessDetail(name: string): void;
21
+ /** Options accepted by `agents harness fork`. */
22
+ export interface ForkOptions {
23
+ model?: string;
24
+ baseUrl?: string;
25
+ authProvider?: string;
26
+ version?: string;
27
+ label?: string;
28
+ description?: string;
29
+ keyStdin?: boolean;
30
+ force?: boolean;
31
+ }
32
+ /**
33
+ * Build the new harness for `agents harness fork <source> <name>`.
34
+ *
35
+ * Two sources, one verb: an existing custom harness is copied and overridden;
36
+ * a native agent id is turned into a harness pinned to `--model` on that host.
37
+ * Forking a native harness therefore requires `--model` — there is nothing to
38
+ * copy a model from.
39
+ */
40
+ export declare function buildFork(source: string, name: string, opts: ForkOptions): Profile;
14
41
  export declare function registerHarnessCommands(program: Command): void;
@@ -11,15 +11,82 @@
11
11
  * native harness registry. The `agents profiles` tree stays unchanged.
12
12
  */
13
13
  import chalk from 'chalk';
14
- import { addProfile } from './profiles.js';
15
- import { listProfiles, readProfile, deleteProfile, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, getProfilePath, } from '../lib/profiles.js';
14
+ import { addProfile, ensureProviderToken } from './profiles.js';
15
+ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, } from '../lib/profiles.js';
16
16
  import { listPresets } from '../lib/profiles-presets.js';
17
- import { AGENTS, ALL_AGENT_IDS } from '../lib/agents.js';
17
+ import { AGENTS, ALL_AGENT_IDS, resolveAgentName } from '../lib/agents.js';
18
18
  /** Short capability summary for a native harness — its supported run modes. */
19
19
  function nativeModes(id) {
20
20
  const modes = AGENTS[id]?.capabilities?.modes ?? [];
21
21
  return modes.length ? modes.join('/') : '-';
22
22
  }
23
+ /**
24
+ * Print one custom harness. Shared by `agents harness view <name>` and by
25
+ * `agents view <name>` — a custom harness resolves as an agent type there, so
26
+ * both entry points must describe it identically.
27
+ */
28
+ export function renderHarnessDetail(name) {
29
+ const p = readProfile(name);
30
+ console.log(chalk.bold(profileLabel(p)) + chalk.gray(' (custom harness)'));
31
+ if (p.description)
32
+ console.log(chalk.gray(p.description));
33
+ console.log('');
34
+ console.log(`Host: ${profileHostLabel(p)}`);
35
+ console.log(`Model: ${profileModelLabel(p)}`);
36
+ if (p.fallback_model)
37
+ console.log(`Fallback: ${p.fallback_model}`);
38
+ console.log(`Provider: ${profileProviderLabel(p)}`);
39
+ console.log(`Auth: ${profileAuthLabel(p)}`);
40
+ if (p.forkedFrom)
41
+ console.log(`Forked: from ${p.forkedFrom}`);
42
+ console.log(chalk.gray(getProfilePath(p.name)));
43
+ console.log('');
44
+ console.log(chalk.gray(`Run: agents run ${p.name} "hello"`));
45
+ }
46
+ /**
47
+ * Build the new harness for `agents harness fork <source> <name>`.
48
+ *
49
+ * Two sources, one verb: an existing custom harness is copied and overridden;
50
+ * a native agent id is turned into a harness pinned to `--model` on that host.
51
+ * Forking a native harness therefore requires `--model` — there is nothing to
52
+ * copy a model from.
53
+ */
54
+ export function buildFork(source, name, opts) {
55
+ if (profileExists(source)) {
56
+ return forkProfile(readProfile(source), name, {
57
+ model: opts.model,
58
+ baseUrl: opts.baseUrl,
59
+ provider: opts.authProvider,
60
+ version: opts.version,
61
+ label: opts.label,
62
+ description: opts.description,
63
+ });
64
+ }
65
+ const host = resolveAgentName(source);
66
+ if (!host) {
67
+ throw new Error(`No harness or agent named '${source}'.\n` +
68
+ `Fork from a custom harness (agents harness list) or a native one: ${ALL_AGENT_IDS.join(', ')}.`);
69
+ }
70
+ if (!opts.model) {
71
+ throw new Error(`--model <id> is required when forking the native '${host}' harness (there is no model to inherit).`);
72
+ }
73
+ return profileFromHostModel(name, host, opts.model, {
74
+ version: opts.version,
75
+ baseUrl: opts.baseUrl,
76
+ provider: opts.authProvider,
77
+ authEnvVar: opts.authProvider ? authEnvKeyForHostOrThrow(host) : undefined,
78
+ label: opts.label,
79
+ description: opts.description ?? `Forked from ${host}: ${opts.model}`,
80
+ });
81
+ }
82
+ /** Auth env var for a host, as a hard error when the host declares none. */
83
+ function authEnvKeyForHostOrThrow(host) {
84
+ const key = authEnvKeyForHost(host);
85
+ if (!key) {
86
+ throw new Error(`--auth-provider is set but host '${host}' has no known auth env var; it manages its own login.`);
87
+ }
88
+ return key;
89
+ }
23
90
  export function registerHarnessCommands(program) {
24
91
  const cmd = program
25
92
  .command('harness')
@@ -30,11 +97,18 @@ A custom harness pins a host CLI (opencode, claude, codex, grok, antigravity, ..
30
97
  model and gives it a name. 'agents run <name>' then behaves like a native agent
31
98
  type, and 'agents repo push user' syncs it to every device.
32
99
 
100
+ A custom harness is its own agent type in 'agents view' — its own block beside Claude
101
+ and Codex, not a row indented under the host CLI that executes it.
102
+
33
103
  Examples:
34
104
  # Meta Muse Spark 1.1 through OpenCode, called 'spark'
35
105
  agents harness add spark --host opencode --model meta/muse-spark-1.1
36
106
  agents run spark "refactor api/handlers/checkout.py"
37
107
 
108
+ # Fork a native harness, or copy one of your own and swap the model
109
+ agents harness fork opencode deepseek --model deepseek/deepseek-v4-flash-0731 --auth-provider openrouter
110
+ agents harness fork deepseek deepseek-chat --model deepseek/deepseek-chat-v3
111
+
38
112
  # Per-run model override still wins
39
113
  agents run spark --model opencode/big-pickle "quick pass"
40
114
 
@@ -66,6 +140,48 @@ Examples:
66
140
  process.exit(1);
67
141
  }
68
142
  });
143
+ cmd
144
+ .command('fork <source> <name>')
145
+ .description('Fork a native harness (claude, opencode, ...) or an existing custom one into a new named harness.')
146
+ .option('--model <id>', 'Model to pin on the fork (required when forking a native harness)')
147
+ .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
148
+ .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider')
149
+ .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
150
+ .option('--label <text>', 'Human-facing name shown by `agents view` (defaults to <name>)')
151
+ .option('--description <text>', 'One-line description')
152
+ .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
153
+ .option('--force', 'Overwrite an existing harness with the same name')
154
+ .addHelpText('after', `
155
+ Examples:
156
+ # Fork OpenCode into a harness pinned to a DeepSeek model on OpenRouter
157
+ agents harness fork opencode deepseek --model deepseek/deepseek-v4-flash-0731 --auth-provider openrouter
158
+
159
+ # Fork Claude Code onto a private gateway
160
+ agents harness fork claude corp --model gpt-x --base-url https://gw.corp/v1 --auth-provider corp
161
+
162
+ # Copy an existing harness and swap only the model
163
+ agents harness fork deepseek deepseek-chat --model deepseek/deepseek-chat-v3
164
+ `)
165
+ .action(async (source, name, opts) => {
166
+ try {
167
+ validateProfileName(name);
168
+ if (profileExists(name) && !opts.force) {
169
+ throw new Error(`Harness '${name}' already exists. Use --force to overwrite.`);
170
+ }
171
+ // Build first so a bad source/flag combination fails before prompting
172
+ // for a key the user would then have stored for nothing.
173
+ const forked = buildFork(source, name, opts);
174
+ if (opts.authProvider)
175
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
176
+ writeProfile(forked);
177
+ console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
178
+ console.log(chalk.gray(`Try: agents run ${name} "hello"`));
179
+ }
180
+ catch (err) {
181
+ console.error(chalk.red(err.message));
182
+ process.exit(1);
183
+ }
184
+ });
69
185
  cmd
70
186
  .command('list')
71
187
  .alias('ls')
@@ -114,16 +230,7 @@ Examples:
114
230
  .description('Show one custom harness (host, model, provider, auth, path).')
115
231
  .action((name) => {
116
232
  try {
117
- const p = readProfile(name);
118
- console.log(chalk.bold(p.name));
119
- if (p.description)
120
- console.log(chalk.gray(p.description));
121
- console.log('');
122
- console.log(`Host: ${profileHostLabel(p)}`);
123
- console.log(`Model: ${profileModelLabel(p)}`);
124
- console.log(`Provider: ${profileProviderLabel(p)}`);
125
- console.log(`Auth: ${profileAuthLabel(p)}`);
126
- console.log(chalk.gray(getProfilePath(p.name)));
233
+ renderHarnessDetail(name);
127
234
  }
128
235
  catch (err) {
129
236
  console.error(chalk.red(err.message));
@@ -15,6 +15,9 @@ import { type Preset } from '../lib/profiles-presets.js';
15
15
  * without mocking @inquirer/prompts.
16
16
  */
17
17
  export declare function buildProfileFromCollection(name: string, preset: Preset, collected: Record<string, string>, version?: string): Profile;
18
+ /** Read all available data from stdin synchronously, trimmed. */
19
+ /** Ensure a provider API key exists in keychain, prompting or reading stdin if missing. */
20
+ export declare function ensureProviderToken(provider: string, signupUrl?: string, fromStdin?: boolean): Promise<void>;
18
21
  /** Options accepted by {@link addProfile} — shared by `agents profiles add` and `agents harness add`. */
19
22
  export interface AddProfileOptions {
20
23
  preset?: string;
@@ -47,7 +47,7 @@ async function promptForSecret(message) {
47
47
  }
48
48
  /** Read all available data from stdin synchronously, trimmed. */
49
49
  /** Ensure a provider API key exists in keychain, prompting or reading stdin if missing. */
50
- async function ensureProviderToken(provider, signupUrl, fromStdin) {
50
+ export async function ensureProviderToken(provider, signupUrl, fromStdin) {
51
51
  const item = keychainItemName(provider);
52
52
  if (hasKeychainToken(item)) {
53
53
  return;
@@ -14,10 +14,29 @@ import type { DeviceRegistry } from '../lib/devices/registry.js';
14
14
  * callers drop it straight into a status line.
15
15
  */
16
16
  export declare function formatRunDuration(startedAt: string, completedAt: string | null): string;
17
+ /**
18
+ * The last run THIS device can speak for.
19
+ *
20
+ * A run record is written by whichever daemon fired the routine, into that
21
+ * machine's own runs dir — records carry no device attribution, so a record
22
+ * found here only ever describes this device's history. When a routine is
23
+ * pinned away from this machine (`devices:` excludes it) any local record is a
24
+ * leftover from before the pin, and reporting it as the routine's status paints
25
+ * another device's healthy routine red. Report nothing instead; the local
26
+ * history stays readable via `agents routines runs <name>`, and the owning
27
+ * device's status via `agents routines list --device <name>`.
28
+ */
29
+ export declare function localLatestRun(job: JobConfig): RunMeta | null;
17
30
  export interface RoutineListGroup {
18
31
  key: string;
19
32
  title: string;
20
33
  jobs: JobConfig[];
34
+ /**
35
+ * Whether this device's run records describe the group. False for a
36
+ * `Device: <peer>` group — the rows are the same routine seen from a machine
37
+ * that does not fire it there, so Last Status is not ours to report.
38
+ */
39
+ local: boolean;
21
40
  }
22
41
  export declare function groupRoutineJobsByDevice(jobs: JobConfig[], registry: DeviceRegistry, self?: string): RoutineListGroup[];
23
42
  export declare function buildRunsJson(runs: RunMeta[]): Record<string, unknown>[];
@@ -138,6 +138,21 @@ function deviceLabel(job, width) {
138
138
  : raw;
139
139
  return { raw, display, dim: full.length === 0 || !jobRunsOnThisDevice(job) };
140
140
  }
141
+ /**
142
+ * The last run THIS device can speak for.
143
+ *
144
+ * A run record is written by whichever daemon fired the routine, into that
145
+ * machine's own runs dir — records carry no device attribution, so a record
146
+ * found here only ever describes this device's history. When a routine is
147
+ * pinned away from this machine (`devices:` excludes it) any local record is a
148
+ * leftover from before the pin, and reporting it as the routine's status paints
149
+ * another device's healthy routine red. Report nothing instead; the local
150
+ * history stays readable via `agents routines runs <name>`, and the owning
151
+ * device's status via `agents routines list --device <name>`.
152
+ */
153
+ export function localLatestRun(job) {
154
+ return jobRunsOnThisDevice(job) ? getLatestRun(job.name) : null;
155
+ }
141
156
  export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
142
157
  const groups = new Map();
143
158
  const add = (key, title, job) => {
@@ -146,7 +161,9 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
146
161
  existing.jobs.push(job);
147
162
  return;
148
163
  }
149
- groups.set(key, { key, title, jobs: [job] });
164
+ // `device:` is the only group that describes a machine other than this one,
165
+ // so it is the only one whose rows this device cannot report a status for.
166
+ groups.set(key, { key, title, jobs: [job], local: !key.startsWith('device:') });
150
167
  };
151
168
  for (const job of jobs) {
152
169
  const strategy = resolveHostStrategy(job);
@@ -193,7 +210,7 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
193
210
  };
194
211
  return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
195
212
  }
196
- function renderRoutineRows({ jobs, scheduler, overdueSet, link, now }) {
213
+ function renderRoutineRows({ jobs, scheduler, overdueSet, link, now, local = true }) {
197
214
  const NAME_W = 24;
198
215
  const AGENT_W = 10;
199
216
  const REPO_W = REPO_DISPLAY_MAX;
@@ -207,7 +224,7 @@ function renderRoutineRows({ jobs, scheduler, overdueSet, link, now }) {
207
224
  for (const job of jobs) {
208
225
  const nextStr = nextRunLabel(job, scheduler, now);
209
226
  const schedStr = scheduleLabel(job);
210
- const latestRun = getLatestRun(job.name);
227
+ const latestRun = local ? localLatestRun(job) : null;
211
228
  const lastStatus = latestRun?.status || '-';
212
229
  const sourceRepo = job.source?.repo ?? job.repo;
213
230
  const sourceLabel = sourceRepo
@@ -509,7 +526,7 @@ export function registerRoutinesCommands(program) {
509
526
  if (options.json) {
510
527
  const nowJson = new Date();
511
528
  const payload = jobs.map((job) => {
512
- const latestRun = getLatestRun(job.name);
529
+ const latestRun = localLatestRun(job);
513
530
  return {
514
531
  name: job.name,
515
532
  agent: job.agent ?? null,
@@ -562,9 +579,14 @@ export function registerRoutinesCommands(program) {
562
579
  catch (err) {
563
580
  console.error(chalk.yellow(`Could not read device registry: ${err.message}`));
564
581
  }
565
- for (const group of groupRoutineJobsByDevice(jobs, registry)) {
582
+ const groups = groupRoutineJobsByDevice(jobs, registry);
583
+ for (const group of groups) {
566
584
  console.log(chalk.bold(`\n${group.title}`));
567
- renderRoutineRows({ jobs: group.jobs, scheduler, overdueSet, link, now });
585
+ renderRoutineRows({ jobs: group.jobs, scheduler, overdueSet, link, now, local: group.local });
586
+ }
587
+ if (groups.some((group) => !group.local)) {
588
+ console.log();
589
+ console.log(chalk.gray(' Last Status is per-device: rows under another device show "-" — read it there with: agents routines list --device <name>'));
568
590
  }
569
591
  }
570
592
  if (overdueSet.size > 0) {
@@ -80,10 +80,19 @@ export declare function exportBundleToFile(env: Record<string, string>, filePath
80
80
  */
81
81
  export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
82
82
  /** The POLICY column for `secrets list`: the prompt policy, plus a concise
83
- * state hint. `daily` shows `held Nh` when the secrets-agent is currently
83
+ * state hint. `hold` shows `held Nh` when the secrets-agent is currently
84
84
  * caching the bundle; `always` and `never` show whether they prompt. `held`
85
85
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
86
86
  export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
87
+ /** The hold-window line at the top of `secrets status`. Names the `hold` policy
88
+ * the window belongs to — the rename in #1604 left this surface still saying
89
+ * "daily", the one name the CLI no longer accepts in its own help. Pure so the
90
+ * vocabulary is pinned by a test rather than re-drifting on the next rename. */
91
+ export declare function renderHoldSummary(holdStr: string, configured: boolean): string;
92
+ /** The empty-broker line under the hold summary. Named here, beside
93
+ * `renderHoldSummary`, for the same reason: it is the second line the rename
94
+ * left saying `daily`, and a test pins both. */
95
+ export declare const NO_BUNDLES_HELD_LINE = "No bundles held. The next read of each hold-policy bundle will prompt once, then hold.";
87
96
  /** Human-readable hold window for `secrets status`. Sub-hour values render in
88
97
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
89
98
  * hours up to 2 days, whole days beyond. Pure — unit-tested. */