@phnx-labs/agents-cli 1.22.63 → 1.22.65
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 +64 -0
- package/dist/commands/accounts.d.ts +8 -7
- package/dist/commands/accounts.js +40 -13
- package/dist/commands/exec.js +40 -0
- package/dist/commands/feed.js +2 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +66 -4
- package/dist/lib/accounting/account-pool-collect.js +7 -2
- package/dist/lib/accounting/account-pool.d.ts +12 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +57 -8
- package/dist/lib/accounting/rotate.js +57 -10
- package/dist/lib/accounting/usage.d.ts +12 -0
- package/dist/lib/accounting/usage.js +119 -15
- package/dist/lib/agent-spec/agents.js +21 -6
- package/dist/lib/claude-account-token.d.ts +26 -0
- package/dist/lib/claude-account-token.js +65 -11
- package/dist/lib/daemon-ticks.js +6 -2
- package/dist/lib/event-families.js +1 -1
- package/dist/lib/exec.d.ts +66 -1
- package/dist/lib/exec.js +104 -8
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +5 -0
- package/dist/lib/feed-broadcast.d.ts +2 -0
- package/dist/lib/feed-broadcast.js +31 -4
- package/dist/lib/harness/adapters/claude.js +33 -27
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +66 -8
- package/dist/lib/identity/client.d.ts +5 -4
- package/dist/lib/identity/client.js +6 -5
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/digest.js +8 -2
- package/dist/lib/session/parse.d.ts +4 -0
- package/dist/lib/session/parse.js +16 -11
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +6 -0
- package/dist/lib/usage-refresh.d.ts +100 -1
- package/dist/lib/usage-refresh.js +195 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.65
|
|
4
|
+
|
|
5
|
+
- **`agents view` shows the last-known usage number with its age instead of hiding it (PHNX-3453).** When the usage endpoint 429-backs-off an account so a window is not refreshed within its own lifetime (Claude's 5h session window) or a billing period rolls over (Grok's weekly credit window), the view now renders the last reading with a staleness suffix — `S: █▌░░░ 30% · 6h old`, `W: ██▏░░ 42% · stale (period ended 1h ago)` — instead of `S: ┄┄┄┄┄ unavailable` or a numberless `run grok once to refresh usage`. The dropped windows ride a new VIEW-ONLY `UsageSnapshot.staleWindows` field; routing still reads only `windows`, so `isUsageVerified`/`hasStaleUsage`/`deriveUsageStatusFromSnapshot` continue to treat a stale/expired number as unverified and never route on it. Source: `cli/src/lib/accounting/usage.ts`.
|
|
6
|
+
|
|
7
|
+
- **A worker's interactive Claude run no longer lands on the login screen (PHNX-3502).** `claudeAdapter.applyExecConfigEnv` deferred to the per-version native login for EVERY interactive run (`ctx.interactive || headedDevice`), even on a `worker`-role device that has no such login — only a headless run on a worker injected the `auth`-bundle setup-token. A keychain-less worker with no `.credentials.json`/`.oauth_token` therefore got no credential at all on `agents run claude --interactive --device <worker>`, landing on Claude Code's theme-picker/login screen. The condition now keys purely on device role: only a `personal`/`desktop` (headed) device defers to its native login; every worker run, interactive or headless, injects the setup-token. The `credentialPresence` routing gate that feeds `--strategy balanced` is also fixed to stop reading a false-healthy signal: its claude entry now requires the REAL credential (`.credentials.json`/`.oauth_token`, via the same floor `getAccountInfo` applies) rather than the bare presence of `.claude.json` (stale account metadata a version can carry with no usable credential behind it). Relatedly, a provider account folded into the run-candidate pool now derives `signedIn` from its record's `secretPresent` field instead of a hardcoded `true` — keeping that field honest rather than changing today's routing, since `localRegistryRecords` already filters out token-less accounts before the map. Source: `cli/src/lib/harness/adapters/claude.ts`, `cli/src/lib/agent-spec/agents.ts`, `cli/src/lib/accounting/account-pool.ts`, `cli/src/lib/accounting/account-pool-collect.ts`.
|
|
8
|
+
|
|
9
|
+
- **Include clickable ticket links in every feed broadcast (PHNX-3572).** Ticket-backed posts now put the canonical Linear URL in the shared message delivered to private owner destinations and configured Slack channel sinks; repeated URL lines are deduplicated. Source: `cli/src/lib/feed-broadcast.ts`, `cli/src/commands/feed.ts`.
|
|
10
|
+
|
|
11
|
+
- **`agents view <agent> --device <host>` no longer flashes and vanishes from a real terminal (PHNX-3583).** The `--device` passthrough set `interactive = process.stdout.isTTY` and forwarded read-only renders over `ssh -tt` — a forced PTY. On the render's clean exit the PTY teardown + local-terminal restore (`restoreLocalTerminal` / `TERMINAL_MODE_RESET`) wiped the just-drawn output, so the whole view flashed and disappeared, leaving the prior screen. The piped (non-TTY) path never had this problem. Pure read-only render commands (`view`, `inspect`, `insights`, `doctor`) now forward over a plain pipe instead of a PTY, and inject `FORCE_COLOR=1` plus `COLUMNS`/`LINES` into the remote env so the piped render still comes back colored and correctly wrapped (`terminalWidth()` already reads `$COLUMNS` for exactly this reason); `FORCE_COLOR` is withheld under `--json` so machine output stays clean. A render command's narrow interactive sub-path — `view --prune`'s confirm — keeps the PTY, and every genuinely interactive command (`run`, `secrets browse`, pickers) is untouched. Source: `cli/src/lib/hosts/passthrough.ts`, `cli/src/lib/hosts/passthrough.test.ts`.
|
|
12
|
+
|
|
13
|
+
- **Remote provisional Claude tabs reopen once instead of bouncing across the fleet (PHNX-3587).** When VS Code reloaded before an offloaded Claude launch had written its first transcript, `agents sessions resume` found the dispatcher's synthetic session row, hopped to the target, then let the target search the fleet and rediscover the same synthetic row. The repeated routing printed reconnect banners and eventually returned to the shell without Claude. An owner hop now performs one local lookup: it resumes a materialized transcript, or recreates the exact forced Claude session identity when the transcript has not materialized yet. Source: `cli/src/commands/resume.ts`.
|
|
14
|
+
|
|
15
|
+
- **Cap and smooth the daemon's usage-refresh traffic with a per-provider budget.** The
|
|
16
|
+
refresher enforced only a per-account hourly cap, so endpoint load scaled linearly
|
|
17
|
+
with account count: 8 Claude accounts × 12/hr = ~96 usage calls/hr from one box,
|
|
18
|
+
against Anthropic's ~100/hr `/api/oauth/usage` ceiling — which 429'd accounts into
|
|
19
|
+
Retry-After parks (measured on `zion`: 7 of 8 accounts parked, never refreshed in
|
|
20
|
+
their window, so `agents view` showed `S: unavailable` and balanced routing read
|
|
21
|
+
stale usage). A per-provider budget (`PROVIDER_HOURLY_BUDGET`, 30/hr across all of a
|
|
22
|
+
network provider's accounts) now bounds aggregate traffic at a fixed rate that no
|
|
23
|
+
longer grows with account count, paced smoothly (one fetch per ~2 min, round-robin,
|
|
24
|
+
stalest account first) so it never bursts-then-stalls. Actively-used accounts stay
|
|
25
|
+
fresh for free via the statusline ingest, which now also re-derives their headroom
|
|
26
|
+
and suppresses a redundant API refresh. Source: `cli/src/lib/usage-refresh.ts`,
|
|
27
|
+
`cli/src/lib/daemon-ticks.ts`.
|
|
28
|
+
- **Route-refuse only on genuinely stale usage, not merely budget-paced usage.** Because
|
|
29
|
+
proactive refreshes are now paced, an idle account is deliberately refreshed on a
|
|
30
|
+
stretched cadence. Routing keeps its tight 5-min bar for *weighting* an account, but
|
|
31
|
+
the fail-loud `NO_VERIFIED_USAGE` refusal now fires on a wider 40-min bar
|
|
32
|
+
(`USAGE_STALE_REFUSAL_MAX_AGE_MS`) — so a budget-paced fleet routes normally while a
|
|
33
|
+
genuinely broken refresh (hours-old readings) still refuses. Source:
|
|
34
|
+
`cli/src/lib/accounting/rotate.ts`.
|
|
35
|
+
|
|
36
|
+
## 1.22.64
|
|
37
|
+
|
|
38
|
+
- **`agents accounts attach` bootstraps a keychain-less Linux worker (PHNX-3502).**
|
|
39
|
+
Attaching a Claude account to a version home on a headless worker now seeds the
|
|
40
|
+
home's identity and writes its `.oauth_token` from the account's non-rotating
|
|
41
|
+
setup-token already synced in the `auth` bundle, instead of refusing because the
|
|
42
|
+
home was never interactively signed in. This is what lets all accounts show as
|
|
43
|
+
logged in — and interactive worker runs authenticate (the shim's Linux
|
|
44
|
+
`.oauth_token` fallback) instead of dropping to the login screen. No rotating
|
|
45
|
+
OAuth credential is ever copied. Source: `apps/cli/src/commands/accounts.ts`,
|
|
46
|
+
`apps/cli/src/lib/claude-account-token.ts`.
|
|
47
|
+
|
|
48
|
+
- Fixed Rush-backed feed channel sinks failing on Linux workers by handing delivery to a reachable macOS fleet peer with the Keychain-bound transport.
|
|
49
|
+
|
|
50
|
+
- **Auto-produce the origin/main release attestation on merge (RUSH-2666).** A new
|
|
51
|
+
`attest-main.yml` workflow runs the full suite on every push to `main` and uploads
|
|
52
|
+
the exact-tree attestation + pretested tarball to a rolling `main-attestations`
|
|
53
|
+
GitHub Release, keyed by tree hash. `release.sh` now prefetches that proof into the
|
|
54
|
+
local store before waiting, so an ordinary release promotes without running the
|
|
55
|
+
suite inline — no more human running the suite by hand to unwedge a release. Purely
|
|
56
|
+
additive and fail-safe: on any fetch miss or error it falls back to exactly the
|
|
57
|
+
prior poll-then-`require` behavior, and a fetched proof is trusted only because the
|
|
58
|
+
existing exact-tree `require` re-verifies it. Source: `cli/scripts/release.sh`,
|
|
59
|
+
`.github/workflows/attest-main.yml`.
|
|
60
|
+
|
|
61
|
+
- **Phoenix ID now uses the branded `id.byphoenix.com` API hostname by default (PHNX-3543).** New CLI sessions start and poll device authorization, resolve `whoami`, and bind managed share/traces Workers against the same canonical Phoenix ID base. The legacy hostname remains live for older installed clients. `PHOENIX_ID_BASE` remains an environment override for development and private deployments. Source: `src/lib/identity/client.ts`.
|
|
62
|
+
|
|
63
|
+
- **A pre-launch `run.launch` event makes a launch into a logged-out version visible instead of silent.** `agents run <agent> --device auto --strategy balanced` (the VS Code "New Claude" flow) launched a version that was LOGGED OUT on the target box: `--device auto` (`applyDeviceAutoToOptions`) only ensures SOME account is ready on the device, not that the SPECIFIC version launched is signed in there, and the failure was invisible — the pinned/default path in `resolveRunVersion` returns without emitting `rotation.resolved`, and `run.dispatched` only fires at run FINALIZE (post-exit), but a logged-out agent sits at the login screen and never finalizes, so nothing was recorded. A new `run.launch` event is now emitted RIGHT BEFORE the harness child is spawned, on the device that will run it, so it fires even when the agent then sits stuck at a login screen. Both live launch paths are covered by one shared emitter: `spawnAgent` (after the tmux-durability gate, for the tmux-wrapped AND bare spawns) and the Windows `execShimPassthrough` shim (`resolvedVia: 'shim'`), which was the second blind spot. Payload: `module: 'run'`, `agent`, `version`, `strategy`, `signedIn` (the launchable-signed-in verdict for the SPECIFIC launched version on THIS device — REUSED from the rotated pick's `rotationResult.picked.signedIn` when the command already computed it, else derived via the new `isVersionLaunchableHere` helper, which applies the same `getVersionHomePath` -> `getAccountInfo` -> `isLaunchableSignedIn` gate as `collectRunCandidates`), `launchedLoggedOut` (the headline flag, `signedIn === false`), `email`, and `resolvedVia`; the device hostname is auto-stamped by `emit()`. It sits in the AUDIT lane — the sibling of `run.dispatched` and the more reliable stuck-launch signal — so `agents events --level audit --include runs` surfaces it. Purely additive observability — the emit is best-effort (it can never break a launch) and no routing/launch behavior changes; refusing to launch a logged-out version is a separate follow-up. Source: `cli/src/lib/exec.ts`, `cli/src/lib/accounting/rotate.ts`, `cli/src/lib/feed/events.ts`, `cli/src/lib/event-families.ts`, `cli/src/commands/exec.ts`.
|
|
64
|
+
|
|
65
|
+
- **Live session rows carry outcome-card metrics and deliverables (PHNX-3574).** `agents sessions --active --json` and `agents sessions watch --json` now enrich each live row with indexed `tokenCount`, `durationMs`, and `subAgentCount`, plus created plan/artifact documents detected by the existing bounded transcript-tail state engine. Thin clients such as AGI EXT can render the initial request, progress, deliverables, team fan-out, runtime, and token use from the one canonical stream without polling or parsing transcripts themselves. Source: `cli/src/lib/session/active.ts`, `cli/src/lib/session/state.ts`.
|
|
66
|
+
|
|
3
67
|
## 1.22.63
|
|
4
68
|
|
|
5
69
|
- **Managed share endpoint enforces a per-user storage quota, object limit, per-file size cap, and publish rate limit (PHNX-3542).** The managed `share.agents-cli.sh` Worker authenticated any Phoenix ID bearer and then accepted **unbounded** writes into shared R2 — no quota, no rate limit, no size cap — which blocked opening publishing to third parties. Each managed (Phoenix-identity) publish now charges a per-user usage ledger stored in R2 at `__usage/<owner>` (a conditional-put CAS object, mirroring the existing `__views`/`__handles` precedent — no Durable Object, no new binding): free tier is 200 MiB total, 150 canonical pages, 20 MiB per file, and 60 publishes/hour. Enforcement measures the **real request body** (bounded-buffered so a streaming body can't exceed the cap) and rejects on the true size **before any write**, so a spoofed-low declared size can't bypass the caps or destroy an existing page. It **fails loud** — `413` for a file, object-count, or byte-quota overage, `429` (with `Retry-After`) for the rate limit — and refunds bytes + object count on delete and on lazy expiry. Covers/views are server-generated overhead and excluded from the quota. BYO (`WRITE_TOKEN`) publishes write to the operator's own bucket at their own cost and are **unaffected** (a deliberate, documented policy). A `SHARE_PLANS` map is the seam for future paid tiers (billing follow-up PHNX-3569). Source: `cli/src/lib/share/worker-template.ts`.
|
|
@@ -30,14 +30,15 @@ export declare function classifyAttachTarget(target: string): AttachTarget;
|
|
|
30
30
|
/**
|
|
31
31
|
* Persist the attached setup-token to a per-version `.oauth_token` file so an
|
|
32
32
|
* INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
|
|
33
|
-
* Headless runs inject the token via `buildExecEnv
|
|
34
|
-
*
|
|
35
|
-
* (`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
|
|
34
|
+
* launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
|
|
35
|
+
* (only a headed `personal`/`desktop` device defers to its per-version native login), and
|
|
36
|
+
* the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
|
|
37
|
+
* so writing it is what makes a freshly-attached setup-token visible to that shim fallback
|
|
38
|
+
* on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
|
|
39
|
+
* returns null there and this is a no-op off Linux.
|
|
39
40
|
*/
|
|
40
|
-
export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId): void;
|
|
41
|
+
export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId, email?: string): void;
|
|
41
42
|
export declare function parseBundleKey(raw: string): {
|
|
42
43
|
bundle: string;
|
|
43
44
|
key: string;
|
|
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { password, select } from '@inquirer/prompts';
|
|
5
|
-
import { resolveClaudeSetupToken } from '../lib/claude-account-token.js';
|
|
5
|
+
import { readClaudeAccountEmail, resolveClaudeSetupToken, resolveClaudeSetupTokenForEmail, seedClaudeWorkerHomeIdentity } from '../lib/claude-account-token.js';
|
|
6
6
|
import { setHelpSections } from '../lib/help.js';
|
|
7
7
|
import { readMeta, updateMeta } from '../lib/state.js';
|
|
8
8
|
import { ALL_AGENT_IDS, getAccountInfo, resolveAgentName } from '../lib/agents.js';
|
|
@@ -84,19 +84,25 @@ export function classifyAttachTarget(target) {
|
|
|
84
84
|
/**
|
|
85
85
|
* Persist the attached setup-token to a per-version `.oauth_token` file so an
|
|
86
86
|
* INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
|
|
87
|
-
* Headless runs inject the token via `buildExecEnv
|
|
88
|
-
*
|
|
89
|
-
* (`
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
87
|
+
* Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
|
|
88
|
+
* launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
|
|
89
|
+
* (only a headed `personal`/`desktop` device defers to its per-version native login), and
|
|
90
|
+
* the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
|
|
91
|
+
* so writing it is what makes a freshly-attached setup-token visible to that shim fallback
|
|
92
|
+
* on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
|
|
93
|
+
* returns null there and this is a no-op off Linux.
|
|
93
94
|
*/
|
|
94
|
-
export function writeClaudeInteractiveOauthToken(target, targetAgent) {
|
|
95
|
+
export function writeClaudeInteractiveOauthToken(target, targetAgent, email) {
|
|
95
96
|
if (process.platform !== 'linux' || targetAgent !== 'claude' || target.kind !== 'installation')
|
|
96
97
|
return;
|
|
97
98
|
const versionHome = getVersionHomePath('claude', target.version);
|
|
98
99
|
const tokenPath = path.join(versionHome, '.claude', '.oauth_token');
|
|
99
|
-
|
|
100
|
+
// Resolve by the attached account's email when known (a freshly-seeded worker
|
|
101
|
+
// home the `.claude.json` read below could not key on yet), else by the home's
|
|
102
|
+
// own recorded identity for a re-point/detach.
|
|
103
|
+
const token = email
|
|
104
|
+
? resolveClaudeSetupTokenForEmail(email, versionHome)
|
|
105
|
+
: resolveClaudeSetupToken(versionHome);
|
|
100
106
|
// A re-point (attach B over A, or a detach) can leave no setup-token resolving for
|
|
101
107
|
// this version — B's may not be minted yet. A leftover file from the previous binding
|
|
102
108
|
// would silently authenticate interactive runs as the OLD account (the shim's Linux
|
|
@@ -536,9 +542,30 @@ agents run codex#work`,
|
|
|
536
542
|
else {
|
|
537
543
|
if (t.kind !== 'installation')
|
|
538
544
|
throw new Error(`${account.agent} authentication is per-version. Attach '${account.name}' to a specific ${account.agent}@<version>.`);
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
545
|
+
const versionHome = getVersionHomePath(t.agent, t.version);
|
|
546
|
+
// The literal email keys the account's `auth`-bundle setup-token. It lives
|
|
547
|
+
// in `identityLabel` — `identityKey` is a synthetic composite
|
|
548
|
+
// (`claude:account=<uuid>:org=<uuid>`, agents.ts nativeIdentityKey), never
|
|
549
|
+
// the address, so it must NOT be used to derive the token key.
|
|
550
|
+
const accountEmail = account.identityLabel;
|
|
551
|
+
// Headless-worker bootstrap: a keychain-less Linux worker home never had
|
|
552
|
+
// an interactive login, so its `.claude.json` carries no identity and
|
|
553
|
+
// `nativeIdentityFromSource` would reject the attach — yet the account's
|
|
554
|
+
// non-rotating setup-token is already fleet-synced in the `auth` bundle.
|
|
555
|
+
// Seed the identity (email only, no rotating credential) so the token
|
|
556
|
+
// resolves; `writeClaudeInteractiveOauthToken` then writes `.oauth_token`.
|
|
557
|
+
if (process.platform === 'linux' &&
|
|
558
|
+
account.agent === 'claude' &&
|
|
559
|
+
accountEmail &&
|
|
560
|
+
!readClaudeAccountEmail(versionHome) &&
|
|
561
|
+
resolveClaudeSetupTokenForEmail(accountEmail)) {
|
|
562
|
+
seedClaudeWorkerHomeIdentity(versionHome, accountEmail);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
const identity = await nativeIdentityFromSource(target);
|
|
566
|
+
if (identity.identityKey !== account.identityKey)
|
|
567
|
+
throw new Error(`'${target}' is signed in to a different identity than account '${account.name}'.`);
|
|
568
|
+
}
|
|
542
569
|
}
|
|
543
570
|
}
|
|
544
571
|
else {
|
|
@@ -546,7 +573,7 @@ agents run codex#work`,
|
|
|
546
573
|
getAccountProvider(account.provider).envFor(targetAgent, account.auth);
|
|
547
574
|
}
|
|
548
575
|
bindAccount(name, target);
|
|
549
|
-
writeClaudeInteractiveOauthToken(t, targetAgent);
|
|
576
|
+
writeClaudeInteractiveOauthToken(t, targetAgent, account.kind === 'native' && account.agent === 'claude' ? account.identityLabel : undefined);
|
|
550
577
|
console.log(chalk.green(`Attached ${account.name} to ${target}.`));
|
|
551
578
|
});
|
|
552
579
|
});
|
package/dist/commands/exec.js
CHANGED
|
@@ -2356,6 +2356,18 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2356
2356
|
// synthesize a same-agent fallback chain from the other healthy accounts
|
|
2357
2357
|
// (issue #348). Stays null unless a non-pinned strategy actually rotated.
|
|
2358
2358
|
let rotationResult = null;
|
|
2359
|
+
// Precomputed launchable-signed-in verdict for the ACTUAL launched
|
|
2360
|
+
// candidate, fed to the pre-launch `run.launch` event so it need not
|
|
2361
|
+
// re-probe. Sourced per resolution branch from the candidate that WON, not
|
|
2362
|
+
// the original auto-pick: the interactive picker (RUSH-2334 / PHNX-2526)
|
|
2363
|
+
// deliberately lets the user launch a LOGGED-OUT account, which is a
|
|
2364
|
+
// different candidate than `rotationResult.picked` — reading the verdict off
|
|
2365
|
+
// the auto-pick there would report `launchedLoggedOut:false` for a version
|
|
2366
|
+
// that is actually logged out, the exact false-negative this event exists to
|
|
2367
|
+
// prevent. Left undefined for pinned-default / explicit-pin so emitRunLaunch
|
|
2368
|
+
// falls back to probing the version home itself.
|
|
2369
|
+
let launchSignedIn;
|
|
2370
|
+
let launchEmail;
|
|
2359
2371
|
// Set when the zero-healthy path already announced a deliberate
|
|
2360
2372
|
// launch-to-sign-in, so the login preflight below does not repeat it.
|
|
2361
2373
|
let signInLaunch = false;
|
|
@@ -2460,6 +2472,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2460
2472
|
if (!selected)
|
|
2461
2473
|
return;
|
|
2462
2474
|
version = selected.version;
|
|
2475
|
+
// Source the run.launch verdict from the account the user ACTUALLY
|
|
2476
|
+
// picked — the picker may deliberately return a logged-out one
|
|
2477
|
+
// (RUSH-2334), so it can differ from rotationResult.picked.
|
|
2478
|
+
launchSignedIn = selected.signedIn;
|
|
2479
|
+
launchEmail = selected.email;
|
|
2463
2480
|
// Keep the rotation so mid-run failover can still cascade across
|
|
2464
2481
|
// the other (stale) healthy accounts after a real rejection.
|
|
2465
2482
|
rotationResult = resolved.rotation;
|
|
@@ -2476,6 +2493,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2476
2493
|
else if (resolved.version) {
|
|
2477
2494
|
version = resolved.version;
|
|
2478
2495
|
rotationResult = resolved.rotation;
|
|
2496
|
+
// The auto-pick already computed the launchable-signed-in verdict for
|
|
2497
|
+
// this exact version via the same gate — reuse it for run.launch.
|
|
2498
|
+
if (resolved.rotation) {
|
|
2499
|
+
launchSignedIn = resolved.rotation.picked.signedIn;
|
|
2500
|
+
launchEmail = resolved.rotation.picked.email;
|
|
2501
|
+
}
|
|
2479
2502
|
// A balanced/available pick of a PROVIDER account (setup-token /
|
|
2480
2503
|
// API-key) carries `providerAccount`. Resolve its env through the
|
|
2481
2504
|
// same `resolveSpawnAccount` path an explicit `--account` uses, so
|
|
@@ -2818,6 +2841,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2818
2841
|
// forwards `--emit-session-id`): print the resolved session id as a
|
|
2819
2842
|
// stdout sentinel so the launcher captures the id this run coined.
|
|
2820
2843
|
emitSessionId: options.emitSessionId === true,
|
|
2844
|
+
// Observability-only: carried onto the pre-launch `run.launch` event so
|
|
2845
|
+
// the stream records HOW this version was chosen. Neither affects the
|
|
2846
|
+
// spawn. `resolvedVia` attributes the version source cheaply — an
|
|
2847
|
+
// explicit @version pin, a strategy rotation, or the pinned default.
|
|
2848
|
+
strategy,
|
|
2849
|
+
resolvedVia: rawVersion
|
|
2850
|
+
? 'explicit-pin'
|
|
2851
|
+
: rotationResult
|
|
2852
|
+
? 'rotated'
|
|
2853
|
+
: 'pinned-default',
|
|
2854
|
+
// Launchable-signed-in verdict for the ACTUAL launched candidate, set per
|
|
2855
|
+
// resolution branch above (auto-pick from rotation.picked; interactive
|
|
2856
|
+
// picker from the user's `selected`, which may be logged out). Undefined
|
|
2857
|
+
// for a pinned-default / explicit-pin launch, where spawnAgent probes the
|
|
2858
|
+
// version home itself.
|
|
2859
|
+
launchSignedIn,
|
|
2860
|
+
launchEmail,
|
|
2821
2861
|
};
|
|
2822
2862
|
if (options.interactive && options.headless) {
|
|
2823
2863
|
console.error(chalk.red('--interactive and --headless are mutually exclusive. Pass one, or neither (mode is inferred from prompt presence).'));
|
package/dist/commands/feed.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildD
|
|
|
3
3
|
import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/feed/activity.js';
|
|
4
4
|
import { projectKeyFromCwd } from '../lib/project-key.js';
|
|
5
5
|
import { postFeedStatus } from '../lib/feed-post.js';
|
|
6
|
+
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
6
7
|
import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, effectiveBroadcastConfig, withDesktopNotify, blockBroadcastContext, blockDeliveryFailure, } from '../lib/feed-broadcast.js';
|
|
7
8
|
import { getSessionById } from '../lib/session/db.js';
|
|
8
9
|
import { readMeta } from '../lib/state.js';
|
|
@@ -697,6 +698,7 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
|
|
|
697
698
|
text: event.detail ?? '',
|
|
698
699
|
level,
|
|
699
700
|
ticket,
|
|
701
|
+
ticketUrl: linearIssueUrl(ticket),
|
|
700
702
|
project: event.project,
|
|
701
703
|
agent: event.agent,
|
|
702
704
|
host: event.host,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
|
|
1
2
|
export interface StrictResumeOptions {
|
|
2
3
|
mode?: string;
|
|
3
4
|
interactive?: boolean;
|
|
@@ -23,6 +24,13 @@ export declare function buildResumeRunArgs(session: {
|
|
|
23
24
|
agent: string;
|
|
24
25
|
version?: string;
|
|
25
26
|
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
27
|
+
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
28
|
+
export declare function buildProvisionalRunArgs(session: {
|
|
29
|
+
id: string;
|
|
30
|
+
agent: string;
|
|
31
|
+
version?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
26
34
|
/** True when the caller asked for the strict resume path (prompt and/or flags). */
|
|
27
35
|
export declare function wantsStrictResume(prompt: string | undefined, options: StrictResumeOptions): boolean;
|
|
28
36
|
/**
|
package/dist/commands/resume.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
|
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { resolveSessionMetadataValue } from './sessions.js';
|
|
9
9
|
import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
|
|
10
|
+
export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
|
|
10
11
|
/**
|
|
11
12
|
* The argv to re-run this resume on the machine that owns the session.
|
|
12
13
|
*
|
|
@@ -46,6 +47,38 @@ export function buildResumeRunArgs(session, prompt, options) {
|
|
|
46
47
|
args.push('--quiet');
|
|
47
48
|
return args;
|
|
48
49
|
}
|
|
50
|
+
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
51
|
+
export function buildProvisionalRunArgs(session, prompt, options) {
|
|
52
|
+
const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
|
|
53
|
+
const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--session-id', session.id];
|
|
54
|
+
if (options.mode)
|
|
55
|
+
args.push('--mode', options.mode);
|
|
56
|
+
if (options.interactive)
|
|
57
|
+
args.push('--interactive');
|
|
58
|
+
if (options.headless)
|
|
59
|
+
args.push('--headless');
|
|
60
|
+
const cwd = options.cwd ?? session.cwd;
|
|
61
|
+
if (cwd)
|
|
62
|
+
args.push('--cwd', cwd);
|
|
63
|
+
if (options.quiet)
|
|
64
|
+
args.push('--quiet');
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
function consumeResumeSource() {
|
|
68
|
+
const raw = process.env[RESUME_SOURCE_ENV];
|
|
69
|
+
delete process.env[RESUME_SOURCE_ENV];
|
|
70
|
+
if (!raw)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(raw);
|
|
74
|
+
if (typeof value?.id === 'string' && typeof value?.agent === 'string')
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// The routing pin is authoritative; malformed optional context is ignored.
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
49
82
|
/** True when the caller asked for the strict resume path (prompt and/or flags). */
|
|
50
83
|
export function wantsStrictResume(prompt, options) {
|
|
51
84
|
return (prompt !== undefined ||
|
|
@@ -63,8 +96,17 @@ export function wantsStrictResume(prompt, options) {
|
|
|
63
96
|
export async function runStrictResume(sessionId, prompt, options) {
|
|
64
97
|
// Read (and clear) the routing pin before anything else, so it can never
|
|
65
98
|
// reach the agent's own children.
|
|
66
|
-
const
|
|
67
|
-
const
|
|
99
|
+
const routedHop = consumeResumePinned();
|
|
100
|
+
const pinnedHere = routedHop || !!options.here;
|
|
101
|
+
const routedSource = routedHop ? consumeResumeSource() : undefined;
|
|
102
|
+
if (routedSource && routedSource.id !== sessionId.trim()) {
|
|
103
|
+
console.error(chalk.red(`Resume routing metadata names session ${routedSource.id}, not requested session ${sessionId.trim()}.`));
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// An owner hop must inspect only the owner's index. Fleet fan-out here can
|
|
108
|
+
// rediscover the dispatcher's synthetic row and bounce the same id forever.
|
|
109
|
+
const outcome = await resolveSessionMetadataValue(sessionId.trim(), pinnedHere ? { local: true } : {});
|
|
68
110
|
if (outcome.kind === 'partial') {
|
|
69
111
|
// RUSH-2492: an unreachable peer is a warning, not a hard failure. The
|
|
70
112
|
// resolver already resolves an id found on the reachable fleet (SES-9a),
|
|
@@ -78,6 +120,19 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
78
120
|
return;
|
|
79
121
|
}
|
|
80
122
|
if (outcome.kind === 'not-found') {
|
|
123
|
+
if (routedHop && routedSource?.filePath === '' && routedSource.agent === 'claude') {
|
|
124
|
+
const args = buildProvisionalRunArgs(routedSource, prompt, options);
|
|
125
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
126
|
+
stdio: 'inherit',
|
|
127
|
+
env: process.env,
|
|
128
|
+
});
|
|
129
|
+
const exitCode = await new Promise((resolve) => {
|
|
130
|
+
child.once('error', () => resolve(127));
|
|
131
|
+
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
132
|
+
});
|
|
133
|
+
process.exitCode = exitCode;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
81
136
|
console.error(chalk.red(`No session matching "${sessionId}".`));
|
|
82
137
|
process.exitCode = 1;
|
|
83
138
|
return;
|
|
@@ -102,7 +157,14 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
102
157
|
// one re-discovers locally and marks the run AGENTS_FLEET_REMOTE, which
|
|
103
158
|
// a long-lived resumed session must not inherit.
|
|
104
159
|
const { runOnPeer } = await import('../lib/session/remote-list.js');
|
|
105
|
-
const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, {
|
|
160
|
+
const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, {
|
|
161
|
+
tty: !!process.stdout.isTTY,
|
|
162
|
+
env: {
|
|
163
|
+
[RESUME_PINNED_ENV]: '1',
|
|
164
|
+
[RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
|
|
165
|
+
},
|
|
166
|
+
sessionId: outcome.session.id,
|
|
167
|
+
});
|
|
106
168
|
if (rc === 'no-target') {
|
|
107
169
|
console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
|
|
108
170
|
console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
|
|
@@ -118,7 +180,7 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
118
180
|
// Avoid repeating the fleet lookup in the delegated local `run`
|
|
119
181
|
// process. The value is metadata-only and is not forwarded over SSH;
|
|
120
182
|
// the owner performs its own local SQLite lookup.
|
|
121
|
-
|
|
183
|
+
[RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
|
|
122
184
|
},
|
|
123
185
|
});
|
|
124
186
|
const exitCode = await new Promise((resolve) => {
|
|
@@ -13,7 +13,7 @@ import { registryPoolCandidates } from './account-pool.js';
|
|
|
13
13
|
function localRegistryRecords() {
|
|
14
14
|
return Object.values(readAccountRegistry().accounts)
|
|
15
15
|
.filter((a) => hasKeychainToken(a.secretRef))
|
|
16
|
-
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth }));
|
|
16
|
+
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* PURE: fold provider-account candidates into the native version-home list.
|
|
@@ -45,7 +45,12 @@ export function foldRegistryCandidates(agent, inputs) {
|
|
|
45
45
|
usageError: null,
|
|
46
46
|
usageMinutesToLimit: null,
|
|
47
47
|
plan: null,
|
|
48
|
-
|
|
48
|
+
// Derive from the record's real secret-presence field rather than a bare
|
|
49
|
+
// `true` literal (PHNX-3502). Inert on today's only producer
|
|
50
|
+
// (`localRegistryRecords` already filters token-less accounts before the
|
|
51
|
+
// map), but keeps the field honest for any injected record that carries
|
|
52
|
+
// `secretPresent: false`.
|
|
53
|
+
signedIn: r.secretPresent,
|
|
49
54
|
authVerdict: null,
|
|
50
55
|
lastActive: null,
|
|
51
56
|
providerAccount: r.name,
|
|
@@ -16,6 +16,16 @@ export interface RegistryAccountRecord {
|
|
|
16
16
|
name: string;
|
|
17
17
|
provider: string;
|
|
18
18
|
auth: AccountAuthKind;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this account's secret is actually present on THIS device
|
|
21
|
+
* (`hasKeychainToken(secretRef)`, checked by the record's builder). Carried
|
|
22
|
+
* through rather than assumed, so a candidate's `signedIn` reflects a real
|
|
23
|
+
* check instead of a literal disconnected from it (PHNX-3502) — a registry
|
|
24
|
+
* entry can exist with no local secret (added on another device, or
|
|
25
|
+
* revoked), and folding it in as unconditionally signed-in would let
|
|
26
|
+
* `--strategy balanced` pick an account that fails at spawn.
|
|
27
|
+
*/
|
|
28
|
+
secretPresent: boolean;
|
|
19
29
|
}
|
|
20
30
|
/** A registry account eligible to run one harness, ready to map to a candidate. */
|
|
21
31
|
export interface RegistryAccountInput {
|
|
@@ -25,6 +35,8 @@ export interface RegistryAccountInput {
|
|
|
25
35
|
name: string;
|
|
26
36
|
provider: string;
|
|
27
37
|
auth: AccountAuthKind;
|
|
38
|
+
/** See {@link RegistryAccountRecord.secretPresent}. */
|
|
39
|
+
secretPresent: boolean;
|
|
28
40
|
}
|
|
29
41
|
/**
|
|
30
42
|
* Which provider accounts can authenticate `agent`. An account is a candidate for
|
|
@@ -131,6 +131,28 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
|
|
|
131
131
|
* existing `signedIn` signal.
|
|
132
132
|
*/
|
|
133
133
|
export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<CredentialPresence, 'knownLocation' | 'perVersion'>): boolean;
|
|
134
|
+
/** Launchable-signed-in verdict for ONE specific version on THIS device. */
|
|
135
|
+
export interface VersionLaunchState {
|
|
136
|
+
/** True iff this exact version home can spawn a signed-in agent right now. */
|
|
137
|
+
launchable: boolean;
|
|
138
|
+
/** The version home's account email when launchable, else null. */
|
|
139
|
+
email: string | null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Whether a SPECIFIC installed version is launchable-signed-in on THIS device,
|
|
143
|
+
* plus the account email when it is. Mirrors EXACTLY the per-version gate
|
|
144
|
+
* {@link collectRunCandidates} applies (getVersionHomePath -> getAccountInfo ->
|
|
145
|
+
* {@link isLaunchableSignedIn} over {@link credentialPresence}), so the
|
|
146
|
+
* pre-launch `run.launch` event can report the same signed-in verdict the
|
|
147
|
+
* balanced router computes for that version.
|
|
148
|
+
*
|
|
149
|
+
* The point is to make a launch into a logged-out version VISIBLE at spawn time:
|
|
150
|
+
* `--device auto` only guarantees SOME account is ready on the device, not that
|
|
151
|
+
* the specific version launched is signed in there (the yosemite-m3 2.1.219
|
|
152
|
+
* incident — 2.1.219 was logged out, the router correctly excluded it, yet it
|
|
153
|
+
* launched). Non-fatal by construction: callers wrap it best-effort.
|
|
154
|
+
*/
|
|
155
|
+
export declare function isVersionLaunchableHere(agent: AgentId, version: string): Promise<VersionLaunchState>;
|
|
134
156
|
/**
|
|
135
157
|
* How old a usage snapshot may be and still settle a routing DECISION.
|
|
136
158
|
*
|
|
@@ -141,6 +163,27 @@ export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<C
|
|
|
141
163
|
* launched into it while the account was actually at its weekly cap.
|
|
142
164
|
*/
|
|
143
165
|
export declare const USAGE_DECISION_MAX_AGE_MS: number;
|
|
166
|
+
/**
|
|
167
|
+
* How old a usage snapshot may be before routing REFUSES to run at all
|
|
168
|
+
* (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
|
|
169
|
+
*
|
|
170
|
+
* These are two different risks and now two different bars. Weighting on a
|
|
171
|
+
* slightly-old number is cheap to get wrong (a floored weight, {@link
|
|
172
|
+
* USAGE_DECISION_MAX_AGE_MS} = 5 min); refusing to launch at all is expensive to
|
|
173
|
+
* get wrong — it fails the user's `agents run` outright. The daemon's usage
|
|
174
|
+
* refresher paces proactive fetches under a fixed per-provider budget
|
|
175
|
+
* (`usage-refresh.ts`, PROVIDER_HOURLY_BUDGET), so on a multi-account fleet an
|
|
176
|
+
* IDLE account is deliberately refreshed on a stretched round-robin cadence
|
|
177
|
+
* (bounded at N × spacing — ~16 min at 8 accounts, ~32 min at 16) rather than
|
|
178
|
+
* every 5 min, which would 429 the endpoint and park it for up to an hour. A
|
|
179
|
+
* budget-paced idle reading of 10–30 min is NOT the failure this refusal exists
|
|
180
|
+
* to catch. That failure is the `yosemite-s1` case: a box whose refresh is
|
|
181
|
+
* genuinely BROKEN, holding readings 26 h – 2.7 d old. 40 min sits comfortably
|
|
182
|
+
* above the worst-case budget cadence and still an order of magnitude below the
|
|
183
|
+
* multi-hour staleness of a broken box — and actively-used accounts refresh for
|
|
184
|
+
* free via the statusline ingest, so a *busy* account is never even this old.
|
|
185
|
+
*/
|
|
186
|
+
export declare const USAGE_STALE_REFUSAL_MAX_AGE_MS: number;
|
|
144
187
|
/**
|
|
145
188
|
* Whether this candidate's usage number is recent enough to route on. A missing
|
|
146
189
|
* snapshot is unverified by definition — there is no number to trust.
|
|
@@ -156,17 +199,23 @@ export declare const USAGE_DECISION_MAX_AGE_MS: number;
|
|
|
156
199
|
*/
|
|
157
200
|
export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
|
|
158
201
|
/**
|
|
159
|
-
* Whether this candidate carries a STALE
|
|
160
|
-
*
|
|
202
|
+
* Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
|
|
203
|
+
* windows whose capture time is older than {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}.
|
|
161
204
|
*
|
|
162
205
|
* This is the misleading case the initial route must refuse — the number reads
|
|
163
206
|
* "48% used" with the same confidence whether captured a minute or three days
|
|
164
|
-
* ago, and a box whose refresh is failing stays wrong indefinitely.
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
207
|
+
* ago, and a box whose refresh is failing stays wrong indefinitely. Two things
|
|
208
|
+
* make it NARROWER than "not verified":
|
|
209
|
+
* 1. It uses the wider REFUSAL bar, not the 5-min weighting bar. A merely
|
|
210
|
+
* budget-paced idle account (10–30 min old) is not-verified — so it weights
|
|
211
|
+
* at the floor, conservatively — but it is NOT "stale" and must not, on its
|
|
212
|
+
* own, drive the whole provider to a NO_VERIFIED_USAGE refusal. Only a
|
|
213
|
+
* genuinely broken refresh (hours old) trips this.
|
|
214
|
+
* 2. A BLIND candidate with no snapshot (or a plan-only meterless one with no
|
|
215
|
+
* windows) carries no number to be misled by — a worker box whose usage
|
|
216
|
+
* endpoint 403s (RUSH-2392), or a meterless Grok login — so it is not
|
|
217
|
+
* "stale", and an entirely-blind pool still draws a pick (PHNX-3392) rather
|
|
218
|
+
* than fail loud with NO_VERIFIED_USAGE.
|
|
170
219
|
*/
|
|
171
220
|
export declare function hasStaleUsage(candidate: RotateCandidate, nowMs?: number): boolean;
|
|
172
221
|
/**
|