@phnx-labs/agents-cli 1.22.68 → 1.22.69
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 +30 -0
- package/README.md +14 -6
- package/dist/bootstrap.js +3 -0
- package/dist/commands/exec.js +31 -21
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +3 -0
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/share.js +43 -14
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/usage.d.ts +7 -2
- package/dist/lib/accounting/usage.js +142 -10
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +4 -0
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/monitors/engine.js +18 -0
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/run-trace-sync.d.ts +15 -0
- package/dist/lib/run-trace-sync.js +43 -21
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +12 -1
- package/dist/lib/session/db.js +20 -1
- package/dist/lib/session/discover.js +81 -1
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +7 -1
- package/dist/lib/session/prompt.js +12 -2
- package/dist/lib/session/recovery.d.ts +21 -12
- package/dist/lib/session/recovery.js +29 -11
- package/dist/lib/session/remote/watch.js +5 -2
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +13 -3
- package/dist/lib/share/publish.js +19 -15
- package/dist/lib/share/worker-template.js +5 -1
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
|
@@ -13,14 +13,121 @@
|
|
|
13
13
|
* `--pane`/`--pty` target a backend directly when the handle is already known.
|
|
14
14
|
*/
|
|
15
15
|
import chalk from 'chalk';
|
|
16
|
-
import { getActiveSessions } from '../lib/session/active.js';
|
|
16
|
+
import { getActiveSessions, shortIdFromName } from '../lib/session/active.js';
|
|
17
17
|
import { injectIntoTerminal, resolveInjectTargetForSession } from '../lib/terminal/index.js';
|
|
18
|
+
import { sshExec, shellQuote } from '../lib/ssh-exec.js';
|
|
19
|
+
import { resolveHost } from '../lib/hosts/registry.js';
|
|
20
|
+
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
18
21
|
import { setHelpSections } from '../lib/help.js';
|
|
22
|
+
/**
|
|
23
|
+
* Whether an active session is the one `sessions inject <token>` means. Matches
|
|
24
|
+
* a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
|
|
25
|
+
* whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
|
|
26
|
+
* tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
|
|
27
|
+
* pane id. Those are the only selectors an id-less remote tmux row exposes, so
|
|
28
|
+
* without this an operator has no tool-native way to nudge it (PHNX-3688).
|
|
29
|
+
*/
|
|
30
|
+
export function matchInjectSelector(session, token) {
|
|
31
|
+
if (!token)
|
|
32
|
+
return false;
|
|
33
|
+
const sid = session.sessionId;
|
|
34
|
+
if (sid && (sid === token || sid.startsWith(token)))
|
|
35
|
+
return true;
|
|
36
|
+
const short = session.tmuxName ? shortIdFromName(session.tmuxName) : undefined;
|
|
37
|
+
if (short && (short === token || short.startsWith(token)))
|
|
38
|
+
return true;
|
|
39
|
+
if (session.tmuxName && session.tmuxName === token)
|
|
40
|
+
return true;
|
|
41
|
+
if (session.paneId && session.paneId === token)
|
|
42
|
+
return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The `--device` selector, normalized to a single host string. `optsWithGlobals()`
|
|
47
|
+
* merges the parent `sessions` command's variadic `-D, --device <target...>` over
|
|
48
|
+
* this subcommand's scalar `--device`, so a single `--device box` arrives as
|
|
49
|
+
* `['box']` — which flowed straight into `sshExec` and crashed on
|
|
50
|
+
* `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
|
|
51
|
+
* several, since inject delivers to exactly one terminal (a fan-out spelling is a
|
|
52
|
+
* user error, not a first-of-list guess).
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeInjectDevice(value) {
|
|
55
|
+
const list = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
56
|
+
const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
|
|
57
|
+
if (hosts.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (hosts.length > 1) {
|
|
60
|
+
throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
|
|
61
|
+
}
|
|
62
|
+
return hosts[0];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `agents sessions inject` argv to re-run ON a device (its tmux panes live
|
|
66
|
+
* there, so resolution must happen there). Every flag rides along EXCEPT
|
|
67
|
+
* `--device`: the command runs on the device, resolving locally. Pure so the
|
|
68
|
+
* forwarded invocation is asserted without an SSH hop (PHNX-3688).
|
|
69
|
+
*/
|
|
70
|
+
export function buildRemoteInjectArgv(sessionId, text, options) {
|
|
71
|
+
const argv = ['agents', 'sessions', 'inject', sessionId, text];
|
|
72
|
+
if (options.enter === false)
|
|
73
|
+
argv.push('--no-enter');
|
|
74
|
+
if (options.combined)
|
|
75
|
+
argv.push('--combined');
|
|
76
|
+
if (options.socket)
|
|
77
|
+
argv.push('--socket', options.socket);
|
|
78
|
+
if (options.pty)
|
|
79
|
+
argv.push('--pty', options.pty);
|
|
80
|
+
if (options.pane)
|
|
81
|
+
argv.push('--pane', options.pane);
|
|
82
|
+
if (options.json)
|
|
83
|
+
argv.push('--json');
|
|
84
|
+
return argv;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
|
|
88
|
+
* `agents sessions inject` there, so a bare session id + `--device` resolves on
|
|
89
|
+
* the box that actually holds the session's tmux panes. The tool-native form of
|
|
90
|
+
* the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* Resolve `--device` to an ssh target. A registered device becomes its
|
|
94
|
+
* `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
|
|
95
|
+
* is handed to ssh verbatim (`resolveHost` returns null for it). A registered
|
|
96
|
+
* device we CANNOT dial — password-auth, addressless — throws its typed error and
|
|
97
|
+
* is NOT degraded to the raw name, which could ssh a coincidentally-matching but
|
|
98
|
+
* unrelated `~/.ssh/config` Host (PHNX-3688 review).
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveInjectSshTarget(device) {
|
|
101
|
+
const host = await resolveHost(device);
|
|
102
|
+
return host ? sshTargetFor(host) : device;
|
|
103
|
+
}
|
|
104
|
+
async function injectOnDevice(sessionId, text, options, device) {
|
|
105
|
+
let target;
|
|
106
|
+
try {
|
|
107
|
+
target = await resolveInjectSshTarget(device);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
if (options.json)
|
|
112
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
113
|
+
else
|
|
114
|
+
console.error(chalk.red(message));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const remoteCmd = buildRemoteInjectArgv(sessionId, text, options).map(shellQuote).join(' ');
|
|
118
|
+
const res = sshExec(target, remoteCmd, { multiplex: true });
|
|
119
|
+
if (res.stdout)
|
|
120
|
+
process.stdout.write(res.stdout);
|
|
121
|
+
if (res.stderr)
|
|
122
|
+
process.stderr.write(res.stderr);
|
|
123
|
+
if (res.code !== 0)
|
|
124
|
+
process.exit(res.code ?? 1);
|
|
125
|
+
}
|
|
19
126
|
/** Resolve a session id (short or full) to an addressable terminal target, via the
|
|
20
127
|
* same resolver the watchdog uses so both agree on what is reachable. */
|
|
21
128
|
async function resolveTarget(sessionId) {
|
|
22
129
|
const sessions = await getActiveSessions();
|
|
23
|
-
const match = sessions.find((s) =>
|
|
130
|
+
const match = sessions.find((s) => matchInjectSelector(s, sessionId));
|
|
24
131
|
if (!match)
|
|
25
132
|
return { target: null, reason: `No active session matches "${sessionId}".` };
|
|
26
133
|
const resolution = resolveInjectTargetForSession(match);
|
|
@@ -35,8 +142,21 @@ async function resolveTarget(sessionId) {
|
|
|
35
142
|
return { target: resolution.target };
|
|
36
143
|
}
|
|
37
144
|
async function runInject(sessionId, text, options) {
|
|
145
|
+
let device;
|
|
146
|
+
try {
|
|
147
|
+
device = normalizeInjectDevice(options.device);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
if (options.json)
|
|
152
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
153
|
+
else
|
|
154
|
+
console.error(chalk.red(message));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
38
157
|
// Direct-target shortcuts skip the session lookup — the watchdog often already
|
|
39
|
-
// holds the pane id or pty session it wants to type into.
|
|
158
|
+
// holds the pane id or pty session it wants to type into. The pane's exact
|
|
159
|
+
// address is known, so it composes with --device (tmux send-keys over SSH).
|
|
40
160
|
let target = null;
|
|
41
161
|
if (options.pty) {
|
|
42
162
|
target = { backend: 'pty', id: options.pty };
|
|
@@ -44,6 +164,12 @@ async function runInject(sessionId, text, options) {
|
|
|
44
164
|
else if (options.pane) {
|
|
45
165
|
target = { backend: 'tmux', pane: options.pane, socket: options.socket };
|
|
46
166
|
}
|
|
167
|
+
else if (device) {
|
|
168
|
+
// A bare session id + a device: the session's tmux panes live ON that device,
|
|
169
|
+
// so getActiveSessions here can't see them. Resolve + deliver THERE by re-running
|
|
170
|
+
// inject over SSH (the same command, minus --device) — PHNX-3688.
|
|
171
|
+
return injectOnDevice(sessionId, text, options, device);
|
|
172
|
+
}
|
|
47
173
|
else {
|
|
48
174
|
const resolved = await resolveTarget(sessionId);
|
|
49
175
|
if (!resolved.target) {
|
|
@@ -60,7 +186,7 @@ async function runInject(sessionId, text, options) {
|
|
|
60
186
|
enter: options.enter !== false,
|
|
61
187
|
combined: options.combined,
|
|
62
188
|
socket: options.socket,
|
|
63
|
-
host:
|
|
189
|
+
host: device,
|
|
64
190
|
});
|
|
65
191
|
if (options.json) {
|
|
66
192
|
console.log(JSON.stringify(res));
|
|
@@ -82,7 +208,7 @@ export function registerSessionsInjectCommand(sessionsCmd) {
|
|
|
82
208
|
.option('--pane <id>', 'Target a tmux pane id directly (e.g. %3), skipping session lookup')
|
|
83
209
|
.option('--pty <id>', 'Target an agents-pty session id directly, skipping session lookup')
|
|
84
210
|
.option('--socket <path>', 'tmux socket path (defaults to the session/shared socket)')
|
|
85
|
-
.option('--device <target>', 'Deliver on a remote device over SSH
|
|
211
|
+
.option('--device <target>', 'Deliver on a remote device over SSH. With a bare session id, the session is resolved ON that device; with --pane, the pane is addressed there directly.')
|
|
86
212
|
.option('--no-enter', 'Send only the text, without a trailing Enter')
|
|
87
213
|
.option('--combined', 'Fuse text + Enter into ONE write (default: two writes, Ink-TUI safe)')
|
|
88
214
|
.option('--json', 'Output the InjectResult as JSON');
|
|
@@ -96,6 +222,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
|
|
|
96
222
|
|
|
97
223
|
# Type into an agents-pty session without submitting
|
|
98
224
|
agents sessions inject _ "ls" --pty $SID --no-enter
|
|
225
|
+
|
|
226
|
+
# Nudge a live session on another box (resolved on the device)
|
|
227
|
+
agents sessions inject 214edaae "continue" --device yosemite-s0
|
|
228
|
+
|
|
229
|
+
# Address a known remote pane directly (skips lookup, sends over SSH)
|
|
230
|
+
agents sessions inject _ "continue" --pane %122 --socket $SOCK --device yosemite-s0
|
|
99
231
|
`,
|
|
100
232
|
notes: `
|
|
101
233
|
- Ink-TUI Enter semantics: by default the text and Enter are two separate
|
|
@@ -103,8 +235,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
|
|
|
103
235
|
- A session is addressable by id when it resolves to a precise split —
|
|
104
236
|
tmux, iTerm, a VSCodium/Cursor/VS Code integrated terminal, or a pty
|
|
105
237
|
(resolveInjectTargetForSession). Use --pane/--pty for direct targeting.
|
|
106
|
-
-
|
|
107
|
-
|
|
238
|
+
- The id may be the session id (short or full) OR the '<shortid>' suffix of
|
|
239
|
+
a tmux target (ag-<agent>-<shortid>) — the only selector a live tmux
|
|
240
|
+
session whose id column shows '-' exposes.
|
|
241
|
+
- Built on the Terminal Engine (src/lib/terminal): with --pane, --device
|
|
242
|
+
runs the tmux send-keys spec over SSH; with a bare id, --device re-runs
|
|
243
|
+
the lookup on that box (its tmux panes live there, not here).
|
|
108
244
|
`,
|
|
109
245
|
});
|
|
110
246
|
// The parent `sessions` command also defines --json, so it binds there;
|
package/dist/commands/share.js
CHANGED
|
@@ -11,11 +11,13 @@ import { Argument, Option } from 'commander';
|
|
|
11
11
|
import chalk from 'chalk';
|
|
12
12
|
import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
|
|
13
13
|
import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
|
|
14
|
-
import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel,
|
|
14
|
+
import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
|
|
15
15
|
import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
|
|
16
16
|
import { renderWorkerBundle } from '../lib/share/worker-template.js';
|
|
17
17
|
import { analyticsEnabled } from '../lib/share/analytics.js';
|
|
18
|
-
import {
|
|
18
|
+
import { extractShareHttpError, formatShareHttpErrorDetail } from '../lib/share/http-error.js';
|
|
19
|
+
import { phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
|
|
20
|
+
import { publishVisibility } from '../lib/storage/visibility.js';
|
|
19
21
|
import { resolveGitHubUsername } from '../lib/git.js';
|
|
20
22
|
import { setHelpSections } from '../lib/help.js';
|
|
21
23
|
import { showUrl } from '../lib/open-url.js';
|
|
@@ -245,7 +247,8 @@ export async function runShareList(opts = {}) {
|
|
|
245
247
|
throw new Error(OUTDATED_TEMPLATE_HINT);
|
|
246
248
|
}
|
|
247
249
|
if (res.status !== 200) {
|
|
248
|
-
|
|
250
|
+
const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
|
|
251
|
+
throw new Error(`Listing failed (${res.status}) for ${listUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
|
|
249
252
|
}
|
|
250
253
|
if (!/application\/json/i.test(res.contentType)) {
|
|
251
254
|
// A 200 that isn't JSON means the old Worker ignored ?format=json and served
|
|
@@ -389,7 +392,8 @@ export async function runShareRevisions(target, opts = {}) {
|
|
|
389
392
|
const fetchListing = opts.fetchListing ?? defaultListingFetch;
|
|
390
393
|
const res = await fetchListing(revUrl);
|
|
391
394
|
if (res.status !== 200) {
|
|
392
|
-
|
|
395
|
+
const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
|
|
396
|
+
throw new Error(`Revisions lookup failed (${res.status}) for ${revUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
|
|
393
397
|
}
|
|
394
398
|
if (!/application\/json/i.test(res.contentType)) {
|
|
395
399
|
if (backend.kind === 'managed') {
|
|
@@ -624,9 +628,8 @@ export function registerShareCommands(artifactsCmd) {
|
|
|
624
628
|
.option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
|
|
625
629
|
.option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
|
|
626
630
|
.option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
|
|
627
|
-
.addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org (
|
|
628
|
-
.choices([...PUBLISH_VISIBILITY_LEVELS])
|
|
629
|
-
.default('public'))
|
|
631
|
+
.addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org. Default when signed in: me (owner-only, Phoenix-gated); use org for your whole email domain, public to opt into the gallery. unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key). All but public are hidden from the gallery')
|
|
632
|
+
.choices([...PUBLISH_VISIBILITY_LEVELS]))
|
|
630
633
|
.addOption(new Option('--protected', 'token-gated link (= --visibility private): the URL carries a secret key and returns 404 without it — the authenticated alternative to --unlisted'))
|
|
631
634
|
.addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted (obscurity, NOT authentication)').hideHelp())
|
|
632
635
|
.addOption(new Option('--private', 'hidden alias of --visibility unlisted (obscurity, NOT authentication — for real read-auth use --protected)').hideHelp())
|
|
@@ -650,11 +653,16 @@ export function registerShareCommands(artifactsCmd) {
|
|
|
650
653
|
}
|
|
651
654
|
try {
|
|
652
655
|
const meta = parseMetaEntries(opts.meta);
|
|
653
|
-
|
|
656
|
+
// Private by default: an explicit flag wins; otherwise the backend-aware
|
|
657
|
+
// product default — `me` (owner-only) when signed in to Phoenix, `public`
|
|
658
|
+
// for a BYO bucket (the Worker refuses `me`/`org` without a Phoenix owner).
|
|
659
|
+
// `shouldUseManaged` reads the same session/BYO signals `publishFile` will
|
|
660
|
+
// resolve, so the default matches the backend the publish actually uses.
|
|
661
|
+
const visibility = publishVisibility({
|
|
654
662
|
visibility: opts.visibility,
|
|
655
663
|
unlisted: opts.unlisted === true || opts.private === true,
|
|
656
664
|
protected: opts.protected === true,
|
|
657
|
-
});
|
|
665
|
+
}, shouldUseManaged({}) ? 'managed' : 'byo');
|
|
658
666
|
// unlisted (incl. its --private alias) is obscurity, not read-auth — warn
|
|
659
667
|
// loudly before the publish so a "private" link is never mistaken for a
|
|
660
668
|
// gated one (PHNX-3654). Skipped for --json so machine output stays clean.
|
|
@@ -684,15 +692,25 @@ export function registerShareCommands(artifactsCmd) {
|
|
|
684
692
|
});
|
|
685
693
|
setHelpSections(shareCmd, {
|
|
686
694
|
examples: `
|
|
687
|
-
# Signed in? Just publish — no Cloudflare setup (managed endpoint)
|
|
695
|
+
# Signed in? Just publish — no Cloudflare setup (managed endpoint).
|
|
696
|
+
# Default is private: an owner-only 'me' link, Phoenix-gated.
|
|
688
697
|
agents auth login
|
|
689
698
|
agents artifacts share ./out/plan.html
|
|
690
699
|
|
|
700
|
+
# Share with everyone at your email domain (no org setup — the domain is it)
|
|
701
|
+
agents artifacts share ./out/plan.html --visibility org
|
|
702
|
+
|
|
703
|
+
# Opt into the public gallery with an OG preview card
|
|
704
|
+
agents artifacts share ./out/landing.html --visibility public
|
|
705
|
+
|
|
691
706
|
# Hide from the public gallery (direct URL still works, noindex) and expire sooner
|
|
692
707
|
agents artifacts share ./out/report.html --visibility unlisted --expire 12h
|
|
693
708
|
|
|
709
|
+
# Token-gated link — GET returns 404 without the secret ?k= key
|
|
710
|
+
agents artifacts share ./out/secret.html --protected
|
|
711
|
+
|
|
694
712
|
# Permanent public page (the slug is derived from its title)
|
|
695
|
-
agents artifacts share ./out/landing.html --expire never
|
|
713
|
+
agents artifacts share ./out/landing.html --visibility public --expire never
|
|
696
714
|
|
|
697
715
|
# Optional slug override
|
|
698
716
|
agents artifacts share ./out/report.html --slug q3-report --expire 7d
|
|
@@ -720,11 +738,21 @@ ${SHARE_DELETE_EXAMPLES}
|
|
|
720
738
|
applies (setup / join). HTML is stored as one object: local images are inlined
|
|
721
739
|
and Chrome-saved file:// TOC links become in-page hashes.
|
|
722
740
|
|
|
741
|
+
Private by default: signed in, a publish with no --visibility is 'me' —
|
|
742
|
+
owner-only, Phoenix-gated, not in the gallery. Pass --visibility org to share
|
|
743
|
+
with everyone at your email domain (no org to create — the domain is the whole
|
|
744
|
+
mechanism), or --visibility public to opt into the gallery with an OG card. A
|
|
745
|
+
BYO publish (no Phoenix session) still defaults to public, since me/org need a
|
|
746
|
+
Phoenix owner to gate on.
|
|
747
|
+
|
|
723
748
|
Default expiry is 30d so an accidental publish decays. Pass --expire never for
|
|
724
749
|
a permanent link. --visibility unlisted (hidden aliases: --unlisted / --private)
|
|
725
750
|
hides the page from the public gallery and agents artifacts share list; the
|
|
726
751
|
direct URL is still world-readable (unlisted, not secret) and GET sends
|
|
727
|
-
X-Robots-Tag: noindex. --
|
|
752
|
+
X-Robots-Tag: noindex. --protected (= --visibility private) is token-gated
|
|
753
|
+
read-auth: the published URL carries a secret ?k= key (Authorization: Bearer
|
|
754
|
+
is accepted too) and GET returns 404 without it — treat the whole link as a
|
|
755
|
+
secret. --visibility me requires a Phoenix session and is only
|
|
728
756
|
visible to the signed-in owner; --visibility org requires the same and is
|
|
729
757
|
visible to members of the same Phoenix organization. A pre-publish scan
|
|
730
758
|
refuses emails and credential-shaped strings unless --force is passed.
|
|
@@ -956,7 +984,7 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
|
|
|
956
984
|
.addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, private, me, org, or all')
|
|
957
985
|
.choices(['public', 'unlisted', 'private', 'me', 'org', 'all'])
|
|
958
986
|
.default('public'))
|
|
959
|
-
.option('--all', "list every page including hidden unlisted/me/org shares (alias for --scope all)")
|
|
987
|
+
.option('--all', "list every page including hidden unlisted/private/me/org shares (alias for --scope all)")
|
|
960
988
|
.option('--agent <name>', 'filter to shares published by this agent/harness (case-insensitive)')
|
|
961
989
|
.option('--session <id>', 'filter to shares published from this session id')
|
|
962
990
|
// Named --label-contains, not --label: `share <file>` (the parent) already owns
|
|
@@ -988,10 +1016,11 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
|
|
|
988
1016
|
# Everything you've published, newest first
|
|
989
1017
|
agents artifacts share list
|
|
990
1018
|
|
|
991
|
-
# Include your hidden pages (unlisted / me / org) so you can see everything
|
|
1019
|
+
# Include your hidden pages (unlisted / private / me / org) so you can see everything
|
|
992
1020
|
agents artifacts share list --all
|
|
993
1021
|
agents artifacts share list --scope me
|
|
994
1022
|
agents artifacts share list --scope unlisted
|
|
1023
|
+
agents artifacts share list --scope private
|
|
995
1024
|
|
|
996
1025
|
# Machine-readable — e.g. pull every still-public URL with jq
|
|
997
1026
|
agents artifacts share list --list-json | jq -r '.objects[].url'
|
package/dist/commands/ssh.js
CHANGED
|
@@ -37,7 +37,7 @@ import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
|
37
37
|
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, isAgentsBrowserDrive, markFleetRemote, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
38
38
|
import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
|
|
39
39
|
import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
|
|
40
|
-
import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
40
|
+
import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, runOnDevice, runLocalCommand, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
41
41
|
import { isRolloutSuccess, verifyFleetRollout, } from '../lib/devices/rollout-verify.js';
|
|
42
42
|
import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
|
|
43
43
|
import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
|
|
@@ -61,6 +61,8 @@ import { runFleetLogin } from '../lib/fleet/remote-login.js';
|
|
|
61
61
|
import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
|
|
62
62
|
import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
|
|
63
63
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
64
|
+
import { isSelfHost } from '../lib/devices/self-host.js';
|
|
65
|
+
import { collectHeldWorktrees, collectHeldWorktreesUnder, summarizeHeld, aggregateHeld, pushStrandedBranch, } from '../lib/worktree/held.js';
|
|
64
66
|
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
65
67
|
* command is running on so it stands out from the rest of the tailnet.
|
|
66
68
|
* `isInteractive` marks the configured interactive host (`devices config <name> interactive.host`). */
|
|
@@ -1130,7 +1132,7 @@ function registerDevicesCommands(program) {
|
|
|
1130
1132
|
{ title: 'Inspect', names: ['list', 'show', 'status', 'ping', 'harnesses', 'accounts', 'snapshot'] },
|
|
1131
1133
|
{ title: 'Disposable devices', names: ['lease'] },
|
|
1132
1134
|
{ title: 'Configure a device', names: ['config', 'describe', 'render'] },
|
|
1133
|
-
{ title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply'] },
|
|
1135
|
+
{ title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply', 'worktrees'] },
|
|
1134
1136
|
]);
|
|
1135
1137
|
devicesCmd
|
|
1136
1138
|
.command('sync')
|
|
@@ -2363,6 +2365,207 @@ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
|
|
|
2363
2365
|
.alias('kill')
|
|
2364
2366
|
.description('Terminate a running dispatched task from this machine (SIGTERM the remote process group; marks it failed/143).')
|
|
2365
2367
|
.action((id) => doDeviceTaskStop(id));
|
|
2368
|
+
const worktreesCmd = devicesCmd
|
|
2369
|
+
.command('worktrees')
|
|
2370
|
+
.description("Surface the held set of agent worktrees the sweep only counts, broken into buckets: unmerged-commits (real stranded work), uncommitted-changes, undeterminable. Read-only; --push publishes stranded branches.")
|
|
2371
|
+
.option('--json', 'emit the structured held set (device, repo, worktree, branch, reason, age, size) for machine callers / fleet aggregation')
|
|
2372
|
+
.option('--home <dir>', 'search root to discover repos under (default: $HOME)')
|
|
2373
|
+
.option('--repo <path>', 'scope to a single repo root instead of discovering')
|
|
2374
|
+
.option('--bucket <name>', 'show only one bucket: unmerged-commits | uncommitted-changes | undeterminable')
|
|
2375
|
+
.option('--fleet', 'fan out across every online device and aggregate the held set fleet-wide')
|
|
2376
|
+
.option('--push', 'PUBLISH each unmerged-commits branch that is on no remote (the safe recovery action); never deletes')
|
|
2377
|
+
.option('--yes', 'skip the confirmation prompt for --push')
|
|
2378
|
+
.action((opts) => runWorktreesHeld(opts));
|
|
2379
|
+
setHelpSections(worktreesCmd, {
|
|
2380
|
+
examples: `
|
|
2381
|
+
Inspect the residue:
|
|
2382
|
+
agents fleet worktrees # this box, grouped by bucket
|
|
2383
|
+
agents fleet worktrees --bucket unmerged-commits
|
|
2384
|
+
agents fleet worktrees --json # structured, for scripts
|
|
2385
|
+
|
|
2386
|
+
Fleet-wide:
|
|
2387
|
+
agents fleet worktrees --fleet # aggregate every online device
|
|
2388
|
+
agents fleet run 'agents fleet worktrees --json' # per-device composition
|
|
2389
|
+
|
|
2390
|
+
Recover stranded work (never deletes):
|
|
2391
|
+
agents fleet worktrees --push # publish on-no-remote branches
|
|
2392
|
+
`,
|
|
2393
|
+
notes: 'The nightly worktree-sweep (phnx-labs/.agents) reclaims merged worktrees and HOLDS the rest, reporting only a count. This surfaces that held set. unmerged-commits is the one that matters — a branch with commits on no remote is the PHNX-2951/PHNX-2732 stranded-work class; --push makes it visible. --push and --fleet are mutually exclusive (recovery is per-device on purpose).',
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
const BUCKET_ORDER = ['unmerged-commits', 'uncommitted-changes', 'undeterminable'];
|
|
2397
|
+
const BUCKET_LABEL = {
|
|
2398
|
+
'unmerged-commits': 'unmerged-commits (stranded work — push or open a PR)',
|
|
2399
|
+
'uncommitted-changes': 'uncommitted-changes (dirty tree — needs review)',
|
|
2400
|
+
'undeterminable': 'undeterminable (broken/locked — re-examine)',
|
|
2401
|
+
};
|
|
2402
|
+
function isHeldBucket(v) {
|
|
2403
|
+
return BUCKET_ORDER.includes(v);
|
|
2404
|
+
}
|
|
2405
|
+
function fmtWtSize(n) {
|
|
2406
|
+
if (n < 0)
|
|
2407
|
+
return '?';
|
|
2408
|
+
if (n < 1024)
|
|
2409
|
+
return `${n}B`;
|
|
2410
|
+
const units = ['K', 'M', 'G', 'T'];
|
|
2411
|
+
let v = n / 1024;
|
|
2412
|
+
let i = 0;
|
|
2413
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
2414
|
+
v /= 1024;
|
|
2415
|
+
i++;
|
|
2416
|
+
}
|
|
2417
|
+
return `${v.toFixed(v < 10 ? 1 : 0)}${units[i]}`;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* `agents fleet worktrees` — surface the held set the sweep only counts. Never
|
|
2421
|
+
* removes anything; `--push` publishes on-no-remote stranded branches. This is
|
|
2422
|
+
* the CLI-native surfacing half of PHNX-3520 (the sweep itself lives in
|
|
2423
|
+
* phnx-labs/.agents; it still emits a bare count until a sibling change teaches
|
|
2424
|
+
* it to consume this).
|
|
2425
|
+
*/
|
|
2426
|
+
async function runWorktreesHeld(opts) {
|
|
2427
|
+
if (opts.bucket && !isHeldBucket(opts.bucket)) {
|
|
2428
|
+
console.error(chalk.red(`Unknown bucket '${opts.bucket}'. Use one of: ${BUCKET_ORDER.join(', ')}`));
|
|
2429
|
+
process.exitCode = 1;
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (opts.fleet && opts.push) {
|
|
2433
|
+
console.error(chalk.red('--push and --fleet are mutually exclusive; run --push on the device that holds the work.'));
|
|
2434
|
+
process.exitCode = 1;
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
if (opts.fleet && opts.repo) {
|
|
2438
|
+
console.error(chalk.red('--repo scopes a single local repo and cannot combine with --fleet; drop one. To scope on each device, run `agents fleet run \'agents fleet worktrees --repo <path> --json\'`.'));
|
|
2439
|
+
process.exitCode = 1;
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
if (opts.fleet) {
|
|
2443
|
+
await runWorktreesHeldFleet(opts);
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const searchHome = opts.home ?? os.homedir();
|
|
2447
|
+
const held = opts.repo
|
|
2448
|
+
? await collectHeldWorktrees(path.resolve(opts.repo))
|
|
2449
|
+
: await collectHeldWorktreesUnder(searchHome);
|
|
2450
|
+
if (opts.push) {
|
|
2451
|
+
await runWorktreesPush(held, opts);
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
const shown = opts.bucket ? held.filter((w) => w.bucket === opts.bucket) : held;
|
|
2455
|
+
if (opts.json) {
|
|
2456
|
+
console.log(JSON.stringify(shown, null, 2));
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
const summary = summarizeHeld(shown);
|
|
2460
|
+
if (summary.total === 0) {
|
|
2461
|
+
console.log(chalk.green('No held worktrees — nothing stranded, dirty, or undeterminable.'));
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
for (const bucket of BUCKET_ORDER) {
|
|
2465
|
+
const items = summary.buckets[bucket];
|
|
2466
|
+
if (items.length === 0)
|
|
2467
|
+
continue;
|
|
2468
|
+
const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
|
|
2469
|
+
console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
|
|
2470
|
+
for (const w of items.sort((a, b) => b.unmergedCommits - a.unmergedCommits)) {
|
|
2471
|
+
const detail = bucket === 'unmerged-commits'
|
|
2472
|
+
? `${w.unmergedCommits} commit${w.unmergedCommits === 1 ? '' : 's'}, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}`
|
|
2473
|
+
: bucket === 'uncommitted-changes'
|
|
2474
|
+
? `${w.dirtyFiles} dirty file${w.dirtyFiles === 1 ? '' : 's'}`
|
|
2475
|
+
: w.reason;
|
|
2476
|
+
console.log(` ${chalk.cyan(w.repoName + '/' + w.name).padEnd(48)} ${chalk.gray((w.branch ?? 'detached').padEnd(28))} ${detail} ${chalk.dim(`${w.ageDays}d · ${fmtWtSize(w.sizeBytes)}`)}`);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
const stranded = summary.buckets['unmerged-commits'].filter((w) => !w.hasRemoteBranch).length;
|
|
2480
|
+
if (stranded > 0) {
|
|
2481
|
+
console.log('\n' + chalk.yellow(`${stranded} branch${stranded === 1 ? '' : 'es'} with unmerged commits on no remote. Recover: agents fleet worktrees --push`));
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
/** `--push`: publish every on-no-remote stranded branch. Never deletes. */
|
|
2485
|
+
async function runWorktreesPush(held, opts) {
|
|
2486
|
+
const candidates = held.filter((w) => w.bucket === 'unmerged-commits' && !w.hasRemoteBranch && w.branch);
|
|
2487
|
+
if (candidates.length === 0) {
|
|
2488
|
+
console.log(chalk.green('No stranded branches to publish (nothing with unmerged commits on no remote).'));
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
if (isInteractiveTerminal() && !opts.yes) {
|
|
2492
|
+
console.log(chalk.yellow(`About to push ${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'} to origin:`));
|
|
2493
|
+
for (const w of candidates)
|
|
2494
|
+
console.log(` ${chalk.cyan(w.repoName + '/' + w.name)} → ${w.branch}`);
|
|
2495
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
2496
|
+
const go = await confirm({ message: 'Push these branches?', default: true }).catch(() => false);
|
|
2497
|
+
if (!go) {
|
|
2498
|
+
console.log(chalk.gray('Cancelled — nothing pushed.'));
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
let pushed = 0;
|
|
2503
|
+
for (const w of candidates) {
|
|
2504
|
+
const res = await pushStrandedBranch(w.repo, w);
|
|
2505
|
+
if (res.pushed) {
|
|
2506
|
+
pushed++;
|
|
2507
|
+
console.log(chalk.green(` pushed ${w.repoName}/${w.name} (${res.branch})`));
|
|
2508
|
+
}
|
|
2509
|
+
else {
|
|
2510
|
+
console.log(chalk.yellow(` skipped ${w.repoName}/${w.name}: ${res.reason}`));
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
console.log('\n' + chalk.green(`Published ${pushed}/${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'}.`));
|
|
2514
|
+
}
|
|
2515
|
+
/** `--fleet`: run this same read-only surface on every online device, aggregate. */
|
|
2516
|
+
async function runWorktreesHeldFleet(opts) {
|
|
2517
|
+
const reg = await loadDevices();
|
|
2518
|
+
const targets = planFleetTargets(reg);
|
|
2519
|
+
const online = targets.filter((t) => !t.skip);
|
|
2520
|
+
if (online.length === 0) {
|
|
2521
|
+
console.log(chalk.gray("No online devices. Run 'agents devices sync' first."));
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
const self = machineId();
|
|
2525
|
+
const remoteCmd = ['agents', 'fleet', 'worktrees', '--json'];
|
|
2526
|
+
if (opts.home)
|
|
2527
|
+
remoteCmd.push('--home', opts.home);
|
|
2528
|
+
const perDevice = [];
|
|
2529
|
+
for (const t of online) {
|
|
2530
|
+
const name = t.device.name;
|
|
2531
|
+
const isSelf = name === self || isSelfHost(name);
|
|
2532
|
+
const res = isSelf ? runLocalCommand(remoteCmd) : runOnDevice(t.device, remoteCmd);
|
|
2533
|
+
if (res.code !== 0) {
|
|
2534
|
+
console.error(chalk.gray(` ${name}: ${(res.stderr || 'failed').trim().slice(0, 120)}`));
|
|
2535
|
+
continue;
|
|
2536
|
+
}
|
|
2537
|
+
try {
|
|
2538
|
+
perDevice.push({ device: name, held: JSON.parse(res.stdout) });
|
|
2539
|
+
}
|
|
2540
|
+
catch {
|
|
2541
|
+
console.error(chalk.gray(` ${name}: unparseable output (older CLI?)`));
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
// Apply the bucket filter to the per-device inputs BEFORE aggregating, so
|
|
2545
|
+
// `total`, per-`devices` totals, and `buckets` all describe the same filtered
|
|
2546
|
+
// set — zeroing buckets after the sum leaves `total` counting rows the output
|
|
2547
|
+
// no longer lists.
|
|
2548
|
+
const scoped = opts.bucket
|
|
2549
|
+
? perDevice.map((d) => ({ device: d.device, held: d.held.filter((w) => w.bucket === opts.bucket) }))
|
|
2550
|
+
: perDevice;
|
|
2551
|
+
const agg = aggregateHeld(scoped);
|
|
2552
|
+
if (opts.json) {
|
|
2553
|
+
console.log(JSON.stringify(agg, null, 2));
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
console.log(chalk.bold(`Held worktrees across ${perDevice.length} device(s): ${agg.total}`));
|
|
2557
|
+
for (const bucket of BUCKET_ORDER) {
|
|
2558
|
+
const items = agg.buckets[bucket];
|
|
2559
|
+
if (items.length === 0)
|
|
2560
|
+
continue;
|
|
2561
|
+
const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
|
|
2562
|
+
console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
|
|
2563
|
+
for (const w of items) {
|
|
2564
|
+
const dev = w.device ?? '?';
|
|
2565
|
+
const detail = bucket === 'unmerged-commits' ? `${w.unmergedCommits} commits, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}` : w.reason;
|
|
2566
|
+
console.log(` ${chalk.magenta(dev.padEnd(14))} ${chalk.cyan((w.repoName + '/' + w.name).padEnd(40))} ${chalk.gray((w.branch ?? 'detached').padEnd(24))} ${detail}`);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2366
2569
|
}
|
|
2367
2570
|
/**
|
|
2368
2571
|
* `agents devices ps` — list tasks dispatched to devices (`agents run --device
|
|
@@ -180,8 +180,13 @@ export interface UsageSnapshot {
|
|
|
180
180
|
* MUST NEVER read this field — `isUsageVerified`/`hasStaleUsage`/
|
|
181
181
|
* `hasUsageAvailable`/`deriveUsageStatusFromSnapshot` consult only `windows`,
|
|
182
182
|
* so a stale number rendered here can never make a stale account read as
|
|
183
|
-
* verified or eligible (the RUSH-2858 property).
|
|
184
|
-
*
|
|
183
|
+
* verified or eligible (the RUSH-2858 property). Not a serialized key of its
|
|
184
|
+
* own, and dropped from `--json` (which projects `windows` explicitly) — but
|
|
185
|
+
* the READINGS it holds do round-trip through the on-disk cache:
|
|
186
|
+
* `serializeClaudeUsageSnapshot` persists the union of `windows` and
|
|
187
|
+
* `staleWindows`, and `deserializeClaudeUsageSnapshot` re-runs the freshness
|
|
188
|
+
* gate on read to re-partition them (so a collector like Grok that pre-splits
|
|
189
|
+
* an ended-period reading onto `staleWindows` still survives the round-trip).
|
|
185
190
|
*/
|
|
186
191
|
staleWindows?: UsageWindow[];
|
|
187
192
|
plan?: string | null;
|