@phnx-labs/agents-cli 1.22.1 → 1.22.4
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 +66 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/exec.js +1 -1
- package/dist/commands/projects.js +25 -1
- package/dist/commands/secrets.js +9 -5
- package/dist/commands/teams.js +2 -2
- package/dist/commands/watchdog.js +64 -5
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +42 -14
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/projects.d.ts +15 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/rotate.d.ts +16 -1
- package/dist/lib/rotate.js +33 -7
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +25 -30
- package/dist/lib/secrets/bundles.js +84 -60
- package/dist/lib/secrets/headless.d.ts +39 -0
- package/dist/lib/secrets/headless.js +63 -0
- package/dist/lib/secrets/index.d.ts +10 -0
- package/dist/lib/secrets/index.js +90 -6
- package/dist/lib/secrets/read-backoff.d.ts +27 -0
- package/dist/lib/secrets/read-backoff.js +64 -0
- package/dist/lib/secrets/session-store.js +6 -4
- package/dist/lib/secrets/vault.js +3 -1
- package/dist/lib/share/config.d.ts +14 -6
- package/dist/lib/share/config.js +23 -5
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.js +3 -1
- package/dist/lib/watchdog/rotate.d.ts +218 -0
- package/dist/lib/watchdog/rotate.js +378 -0
- package/dist/lib/watchdog/runner.d.ts +33 -1
- package/dist/lib/watchdog/runner.js +303 -0
- package/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative memo for failed/cancelled macOS keychain reads (the "back-off").
|
|
3
|
+
*
|
|
4
|
+
* A cancelled Touch ID sheet is the user saying "not now" — but a polling
|
|
5
|
+
* caller (the Factory extension host's `agents view` loop, a watch script)
|
|
6
|
+
* retries the same read a few seconds later and pops the sheet again, forever.
|
|
7
|
+
* The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
|
|
8
|
+
* no-TTY case; this memo covers a context that CAN prompt but just had its
|
|
9
|
+
* prompt cancelled or fail: the next read of the same item within the TTL
|
|
10
|
+
* throws the back-off error instead of re-prompting. Any successful read (or
|
|
11
|
+
* write) of the item clears the memo.
|
|
12
|
+
*
|
|
13
|
+
* Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
|
|
14
|
+
* one file per item (filename is a hash of the item name; the file carries no
|
|
15
|
+
* secret material — a name and a deadline only). All operations are
|
|
16
|
+
* best-effort: a lost memo costs at most one extra prompt, never a read.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
/** How long a failed/cancelled read suppresses retries of the same item. */
|
|
23
|
+
export const KEYCHAIN_READ_BACKOFF_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
let dirOverride = null;
|
|
25
|
+
/** Test seam: point the memo at a temp dir so tests never touch the real cache. */
|
|
26
|
+
export function setKeychainReadBackoffDirForTest(dir) {
|
|
27
|
+
dirOverride = dir;
|
|
28
|
+
}
|
|
29
|
+
function backoffDir() {
|
|
30
|
+
return dirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'keychain-read-backoff');
|
|
31
|
+
}
|
|
32
|
+
function backoffFile(key) {
|
|
33
|
+
const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
|
|
34
|
+
return path.join(backoffDir(), `${digest}.json`);
|
|
35
|
+
}
|
|
36
|
+
/** True while `key` is inside the back-off window opened by a failed/cancelled read. */
|
|
37
|
+
export function isKeychainReadBackedOff(key, now = Date.now()) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(backoffFile(key), 'utf8'));
|
|
40
|
+
return typeof parsed.until === 'number' && parsed.until > now;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false; // absent or malformed memo → no back-off
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
|
|
47
|
+
export function noteKeychainReadFailure(key, now = Date.now()) {
|
|
48
|
+
try {
|
|
49
|
+
fs.mkdirSync(backoffDir(), { recursive: true, mode: 0o700 });
|
|
50
|
+
fs.writeFileSync(backoffFile(key), JSON.stringify({ item: key, until: now + KEYCHAIN_READ_BACKOFF_TTL_MS }), { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* best-effort — the cache dir is regenerable; a lost memo costs one prompt */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Clear the memo: a successful read or write of the item resets the back-off. */
|
|
57
|
+
export function clearKeychainReadBackoff(key) {
|
|
58
|
+
try {
|
|
59
|
+
fs.rmSync(backoffFile(key), { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -89,7 +89,8 @@ function sessionBlobItem(name, harness) {
|
|
|
89
89
|
/** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
|
|
90
90
|
export function readIndex() {
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
// Written no-ACL (writeIndex below) — attest so a headless resolve stays silent.
|
|
93
|
+
const raw = getKeychainToken(SESSION_INDEX_ITEM, { silentNoAcl: true });
|
|
93
94
|
const parsed = JSON.parse(raw);
|
|
94
95
|
if (parsed && typeof parsed === 'object' && parsed.bundles)
|
|
95
96
|
return parsed;
|
|
@@ -145,7 +146,8 @@ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
|
|
|
145
146
|
if (!shouldPersist())
|
|
146
147
|
return null;
|
|
147
148
|
try {
|
|
148
|
-
|
|
149
|
+
// Written no-ACL (saveSession) — attest so a headless resolve stays silent.
|
|
150
|
+
const raw = getKeychainToken(sessionBlobItem(name, harness), { silentNoAcl: true });
|
|
149
151
|
const entry = JSON.parse(raw);
|
|
150
152
|
if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
|
|
151
153
|
return null;
|
|
@@ -227,7 +229,7 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
227
229
|
if (key.includes(':'))
|
|
228
230
|
continue;
|
|
229
231
|
try {
|
|
230
|
-
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}
|
|
232
|
+
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`, { silentNoAcl: true });
|
|
231
233
|
const legacy = JSON.parse(raw);
|
|
232
234
|
setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
233
235
|
deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
@@ -265,7 +267,7 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
265
267
|
continue;
|
|
266
268
|
}
|
|
267
269
|
try {
|
|
268
|
-
const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
270
|
+
const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'), { silentNoAcl: true });
|
|
269
271
|
const legacy = JSON.parse(raw);
|
|
270
272
|
setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
271
273
|
deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
@@ -209,7 +209,9 @@ export function clearVaultKey() {
|
|
|
209
209
|
export function getVaultSession() {
|
|
210
210
|
let raw;
|
|
211
211
|
try {
|
|
212
|
-
|
|
212
|
+
// Written no-ACL (cacheVaultKey) — the vault session probe runs inside bundleBackend
|
|
213
|
+
// for EVERY bundle op, headless included, so it must stay silent.
|
|
214
|
+
raw = getKeychainToken(VAULT_SESSION_ITEM, { silentNoAcl: true });
|
|
213
215
|
}
|
|
214
216
|
catch {
|
|
215
217
|
return { loggedIn: false };
|
|
@@ -32,12 +32,20 @@ export declare function readWriteTokenFromBundle(): string;
|
|
|
32
32
|
/** Read the raw write token from injected env first, then the local bundle.
|
|
33
33
|
* Throws with an actionable message if absent (run setup/join first). */
|
|
34
34
|
export declare function readWriteToken(): string;
|
|
35
|
-
/** Best-effort runtime env for spawned agents. Never throws
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
/** Best-effort runtime env for spawned agents. Never throws AND never prompts.
|
|
36
|
+
*
|
|
37
|
+
* Auto-injecting the share write token on every `agents run` is a background
|
|
38
|
+
* convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
|
|
39
|
+
* ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
|
|
40
|
+
* per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
|
|
41
|
+
* so an interactive read here spawned the helper and popped Touch ID on EVERY
|
|
42
|
+
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
43
|
+
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
44
|
+
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
45
|
+
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
46
|
+
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
47
|
+
* share never`). */
|
|
48
|
+
export declare function shareRuntimeEnv(): Record<string, string> | undefined;
|
|
41
49
|
/** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
|
|
42
50
|
* user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
|
|
43
51
|
export declare function readCloudflareCreds(bundle?: string, override?: {
|
package/dist/lib/share/config.js
CHANGED
|
@@ -56,6 +56,14 @@ export function storeWriteToken(token) {
|
|
|
56
56
|
bundle = {
|
|
57
57
|
name: SHARE_BUNDLE,
|
|
58
58
|
description: 'agents share — write token for the R2 share endpoint',
|
|
59
|
+
// A NEW share bundle defaults to the `never` tier (no biometry ACL). The R2
|
|
60
|
+
// write token is low-sensitivity automation infra that is auto-read on EVERY
|
|
61
|
+
// `agents run` (shareRuntimeEnv) — a biometry ACL there is what produced the
|
|
62
|
+
// per-run Touch ID storm. `never` stores it no-ACL so auto-share is silent
|
|
63
|
+
// and needs no unlock. An EXISTING bundle keeps its tier (we never silently
|
|
64
|
+
// downgrade one the user already made); change it explicitly with
|
|
65
|
+
// `agents secrets policy share <tier>`.
|
|
66
|
+
policy: 'never',
|
|
59
67
|
vars: {},
|
|
60
68
|
};
|
|
61
69
|
}
|
|
@@ -82,10 +90,20 @@ export function readWriteTokenFromBundle() {
|
|
|
82
90
|
export function readWriteToken() {
|
|
83
91
|
return readWriteTokenEnv() ?? readWriteTokenFromBundle();
|
|
84
92
|
}
|
|
85
|
-
/** Best-effort runtime env for spawned agents. Never throws
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
93
|
+
/** Best-effort runtime env for spawned agents. Never throws AND never prompts.
|
|
94
|
+
*
|
|
95
|
+
* Auto-injecting the share write token on every `agents run` is a background
|
|
96
|
+
* convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
|
|
97
|
+
* ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
|
|
98
|
+
* per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
|
|
99
|
+
* so an interactive read here spawned the helper and popped Touch ID on EVERY
|
|
100
|
+
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
101
|
+
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
102
|
+
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
103
|
+
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
104
|
+
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
105
|
+
* share never`). */
|
|
106
|
+
export function shareRuntimeEnv() {
|
|
89
107
|
if (!readShareConfig())
|
|
90
108
|
return undefined;
|
|
91
109
|
const fromEnv = readWriteTokenEnv();
|
|
@@ -97,7 +115,7 @@ export function shareRuntimeEnv(opts = {}) {
|
|
|
97
115
|
const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
|
|
98
116
|
caller: 'share',
|
|
99
117
|
keys: [SHARE_TOKEN_KEY],
|
|
100
|
-
agentOnly:
|
|
118
|
+
agentOnly: true, // never raise a Touch ID sheet on an agent launch (SEC-13)
|
|
101
119
|
});
|
|
102
120
|
const token = env[SHARE_TOKEN_KEY];
|
|
103
121
|
return token ? { [SHARE_TOKEN_ENV_KEY]: token } : undefined;
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -764,6 +764,16 @@ export interface Meta {
|
|
|
764
764
|
*/
|
|
765
765
|
isolatedAgents?: Partial<Record<AgentId, string>>;
|
|
766
766
|
run?: RunConfig;
|
|
767
|
+
/**
|
|
768
|
+
* Daemon watchdog config. `rotate` (default `on`) lets the watchdog rotate a
|
|
769
|
+
* rate-limited session IN PLACE onto a healthy account/harness via
|
|
770
|
+
* `agents run auto` — see lib/watchdog/rotate.ts. Set `off` to keep the
|
|
771
|
+
* nudge-only behavior (the Factory `agents.watchdog.autoRotate: false`
|
|
772
|
+
* migration writes `off` here).
|
|
773
|
+
*/
|
|
774
|
+
watchdog?: {
|
|
775
|
+
rotate?: 'on' | 'off';
|
|
776
|
+
};
|
|
767
777
|
/**
|
|
768
778
|
* `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
|
|
769
779
|
* whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.
|
package/dist/lib/usage.js
CHANGED
|
@@ -1246,7 +1246,9 @@ function claudeOauthCacheActive() {
|
|
|
1246
1246
|
* or the token itself has expired — in which case the stale entry is dropped. */
|
|
1247
1247
|
function readCachedClaudeOauth(service) {
|
|
1248
1248
|
try {
|
|
1249
|
-
|
|
1249
|
+
// The cache item is written no-ACL (writeCachedClaudeOauth) — its whole purpose is
|
|
1250
|
+
// serving the token prompt-free, so attest that to the raw-read storm guard.
|
|
1251
|
+
const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service), { silentNoAcl: true }));
|
|
1250
1252
|
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
|
|
1251
1253
|
return null;
|
|
1252
1254
|
const now = Date.now();
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog rotate — in-place rotation of a rate-limited session onto a healthy
|
|
3
|
+
* account/harness, inside the SAME terminal tab (one-watchdog; follow-up to
|
|
4
|
+
* RUSH-2132 / PR #1875).
|
|
5
|
+
*
|
|
6
|
+
* When a stalled session's transcript tail shows a hard limit ("You've hit your
|
|
7
|
+
* weekly limit · resets …"), the daemon watchdog rotates it instead of nudging:
|
|
8
|
+
*
|
|
9
|
+
* 1. DETECT — classifyTailForRotate() matches the tail against the limit
|
|
10
|
+
* patterns (ported from apps/factory/src/core/autoRotate.ts) and parses the
|
|
11
|
+
* `resets <time>` clause when present.
|
|
12
|
+
* 2. GATE — defaultRotateGate() runs the SAME first-party selection
|
|
13
|
+
* `agents run auto` would (collectHarnessCandidates + pickHarnessWeighted,
|
|
14
|
+
* ../rotate.ts). Zero healthy → ONE `rotate` skip event per cooldown window
|
|
15
|
+
* and the terminal is left untouched. No `agents view` subprocess anywhere.
|
|
16
|
+
* 3. RELAUNCH — the per-harness exit sequence (ported from apps/factory
|
|
17
|
+
* prewarm.ts PREWARM_CONFIGS) is injected, then
|
|
18
|
+
* `agents run auto --interactive --session-id <uuid>`.
|
|
19
|
+
* 4. REPLAY — when the new session's TUI is live (bounded wait, default
|
|
20
|
+
* 60s), the resume replay is injected. On timeout the session is flagged
|
|
21
|
+
* and the machine stops — never blind-type into a dead shell.
|
|
22
|
+
*
|
|
23
|
+
* The machine spans ticks (the exit sequence kills the old session, so it drops
|
|
24
|
+
* out of the active-session list before the new TUI is live): state persists at
|
|
25
|
+
* <watchdog-state>/rotate/<sessionId>.json as
|
|
26
|
+
* exiting → launching → awaiting-tui → replaying → done | failed.
|
|
27
|
+
*
|
|
28
|
+
* Config: `watchdog.rotate: on|off` in agents.yaml (default on), read per tick.
|
|
29
|
+
*/
|
|
30
|
+
import type { InjectTarget } from '../terminal/inject.js';
|
|
31
|
+
import type { ActiveSession } from '../session/active.js';
|
|
32
|
+
/**
|
|
33
|
+
* Agent-reported hard-limit texts, matched against a session transcript tail.
|
|
34
|
+
* Ported verbatim from apps/factory/src/core/autoRotate.ts RATE_LIMIT_PATTERNS —
|
|
35
|
+
* kept specific on purpose: a transcript carries prose, so a loose "rate limit"
|
|
36
|
+
* match would rotate terminals whose agent merely DISCUSSED limits. The first
|
|
37
|
+
* two patterns cover the weekly/session variants, including claude's
|
|
38
|
+
* "You've hit your weekly limit · resets <time>" form.
|
|
39
|
+
*/
|
|
40
|
+
export declare const ROTATE_LIMIT_PATTERNS: RegExp[];
|
|
41
|
+
export type RotateTailVerdict = {
|
|
42
|
+
kind: 'none';
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'rate_limited';
|
|
45
|
+
resetsAtMs?: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Classify a transcript tail for the rotate decision: does it show a hard
|
|
49
|
+
* account limit (rotate this session) or not (leave it to the nudge path)?
|
|
50
|
+
* Unlike the retired extension path there is NO `no healthy` tail parsing here —
|
|
51
|
+
* the health gate is a first-party function call (defaultRotateGate), not a
|
|
52
|
+
* cross-package string contract.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyTailForRotate(tailLines: string[], nowMs: number): RotateTailVerdict;
|
|
55
|
+
/**
|
|
56
|
+
* Parse the `resets <time>` clause of a limit line into an epoch-ms horizon.
|
|
57
|
+
* Ported from apps/factory/src/core/autoRotate.ts parseResetTimeMs (behavior
|
|
58
|
+
* verbatim): the ISO form (milliseconds + Z) is matched EXPLICITLY and first —
|
|
59
|
+
* a generic capture stops at the milliseconds dot and drops the Z, which makes
|
|
60
|
+
* Date.parse read LOCAL time (the suppression would end hours off). Time-of-day
|
|
61
|
+
* forms like `7am` / `7:30pm` with an optional `(Area/City)` IANA zone cover
|
|
62
|
+
* claude's own limit text. Returns undefined when no usable reset is present or
|
|
63
|
+
* the parsed time is already past (caller falls back to its default cooldown).
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseRotateResetMs(text: string, nowMs: number): number | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Clean-exit key sequences per harness, ported verbatim from apps/factory
|
|
68
|
+
* prewarm.ts PREWARM_CONFIGS. Injected as RAW BYTES with no trailing Enter — a
|
|
69
|
+
* literal \x03 written to the pty IS Ctrl+C (SIGINT), \x1b IS Esc. claude's Ink
|
|
70
|
+
* TUI needs the Esc first to leave any open mode before the interrupt pair.
|
|
71
|
+
*/
|
|
72
|
+
export declare const ROTATE_EXIT_SEQUENCES: Record<string, string[]>;
|
|
73
|
+
/** Unknown harnesses get the common denominator: Ctrl+C twice. */
|
|
74
|
+
export declare const DEFAULT_ROTATE_EXIT_SEQUENCE: string[];
|
|
75
|
+
export declare function exitSequenceFor(agent: string): string[];
|
|
76
|
+
/**
|
|
77
|
+
* The rotate relaunch, typed into the same tab: full auto — the CLI resolves
|
|
78
|
+
* host (affinity) → harness (cross-harness headroom) → account (balanced) and
|
|
79
|
+
* exits nonzero when every layer is exhausted. Ported from apps/factory
|
|
80
|
+
* autoRotate.ts buildAutoRotateLaunchCommand. A terminal on a REMOTE device
|
|
81
|
+
* rotates ON that device (`--host`); a local terminal omits it. `--session-id`
|
|
82
|
+
* is honored only when the CLI picks claude (existing claude-only semantics)
|
|
83
|
+
* and ignored otherwise — passing it unconditionally keeps the terminal's
|
|
84
|
+
* AGENT_SESSION_ID aligned with the session Claude actually creates.
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildRotateLaunchCommand(opts: {
|
|
87
|
+
host?: string;
|
|
88
|
+
sessionId: string;
|
|
89
|
+
}): string;
|
|
90
|
+
/**
|
|
91
|
+
* The harness-agnostic replay injected once the new TUI is live: load the OLD
|
|
92
|
+
* session's transcript, assess, continue. This is the same instruction shape
|
|
93
|
+
* the CLI's own `continue` flow uses.
|
|
94
|
+
*/
|
|
95
|
+
export declare function buildRotateReplayText(oldSessionId: string): string;
|
|
96
|
+
export type RotatePhase = 'exiting' | 'launching' | 'awaiting-tui' | 'replaying' | 'done' | 'failed';
|
|
97
|
+
/** Persisted at <watchdog-state>/rotate/<sessionId>.json — keyed by the OLD session id. */
|
|
98
|
+
export interface RotateState {
|
|
99
|
+
/** The OLD (rate-limited) session id — the file key and the replay target. */
|
|
100
|
+
sessionId: string;
|
|
101
|
+
/** The id passed to `--session-id` on the relaunch. */
|
|
102
|
+
newSessionId: string;
|
|
103
|
+
/** The harness that was rate-limited (drives the exit-sequence table). */
|
|
104
|
+
agent: string;
|
|
105
|
+
phase: RotatePhase;
|
|
106
|
+
/** The resolved inject target — serializable, so the sweep can replay without re-resolving. */
|
|
107
|
+
target: InjectTarget;
|
|
108
|
+
/** Remote device the terminal lives on, when provenance says ssh. */
|
|
109
|
+
host?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The old session's cwd — correlates the readiness fallback: a fresh active
|
|
112
|
+
* session only counts as the relaunched TUI when it runs in the SAME project.
|
|
113
|
+
*/
|
|
114
|
+
cwd?: string;
|
|
115
|
+
/**
|
|
116
|
+
* The machine the old session runs on (provenance host = os.hostname()) —
|
|
117
|
+
* the second half of the readiness-fallback correlation, so a fresh session
|
|
118
|
+
* on ANOTHER box never satisfies it.
|
|
119
|
+
*/
|
|
120
|
+
machineHost?: string;
|
|
121
|
+
startedAtMs: number;
|
|
122
|
+
updatedAtMs: number;
|
|
123
|
+
/** awaiting-tui deadline: startedAtMs + readiness budget. */
|
|
124
|
+
deadlineMs: number;
|
|
125
|
+
error?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Set on the transition to `failed`: the tick will not re-begin a rotate for
|
|
128
|
+
* this session until then (default +15m). Without it a session whose old TUI
|
|
129
|
+
* ignored the exit sequence re-enters begin → deadline → failed every tick.
|
|
130
|
+
*/
|
|
131
|
+
suppressUntilMs?: number;
|
|
132
|
+
}
|
|
133
|
+
/** Bounded wait for the relaunched TUI to come live (readiness). */
|
|
134
|
+
export declare const DEFAULT_ROTATE_READINESS_MS = 60000;
|
|
135
|
+
/** Zero-healthy skip cooldown when neither the gate nor the tail carries a reset. */
|
|
136
|
+
export declare const DEFAULT_ROTATE_SKIP_COOLDOWN_MS: number;
|
|
137
|
+
/** Retry cooldown after a FAILED rotate — honored at begin via the state file. */
|
|
138
|
+
export declare const DEFAULT_ROTATE_FAILED_COOLDOWN_MS: number;
|
|
139
|
+
export declare function rotateStatePath(dir: string, sessionId: string): string;
|
|
140
|
+
export declare function readRotateState(dir: string, sessionId: string): RotateState | null;
|
|
141
|
+
export declare function writeRotateState(dir: string, state: RotateState): void;
|
|
142
|
+
export declare function clearRotateState(dir: string, sessionId: string): void;
|
|
143
|
+
/** A phase the machine still has work to do in (done/failed are terminal). */
|
|
144
|
+
export declare function isInflightPhase(phase: RotatePhase): boolean;
|
|
145
|
+
/** Every persisted rotate state (any phase) — for `watchdog status`. */
|
|
146
|
+
export declare function listRotateStates(dir: string): RotateState[];
|
|
147
|
+
/** In-flight rotates only — the set a tick's sweep must advance. */
|
|
148
|
+
export declare function listInflightRotates(dir: string): RotateState[];
|
|
149
|
+
/**
|
|
150
|
+
* One `rotate` skip event per cooldown window, tracked as
|
|
151
|
+
* <watchdog-state>/rotate-skips.json: { [sessionId]: suppressUntilMs }. A skip
|
|
152
|
+
* inside the window logs nothing and touches nothing.
|
|
153
|
+
*/
|
|
154
|
+
export declare function readRotateSkipLedger(dir: string): Record<string, number>;
|
|
155
|
+
/** True when a skip for this session is OUTSIDE its suppression window (log it). */
|
|
156
|
+
export declare function shouldLogRotateSkip(dir: string, sessionId: string, nowMs: number): boolean;
|
|
157
|
+
/** Suppress further skip events for this session until suppressUntilMs. */
|
|
158
|
+
export declare function recordRotateSkip(dir: string, sessionId: string, suppressUntilMs: number): void;
|
|
159
|
+
/**
|
|
160
|
+
* `watchdog.rotate` in agents.yaml (default ON — it is safe now: the health gate
|
|
161
|
+
* is first-party and the readiness wait is bounded). Read fresh per tick so a
|
|
162
|
+
* flip mid-run is honored on the next pass.
|
|
163
|
+
*/
|
|
164
|
+
export declare function isWatchdogRotateEnabled(): boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Persist `watchdog.rotate: on|off`. Called by the `agents watchdog rotate
|
|
167
|
+
* on|off` subcommand (commands/watchdog.ts) — the rotate-only switch the
|
|
168
|
+
* Factory migration uses so a user who opted out of autoRotate keeps nudging
|
|
169
|
+
* (rather than `agents watchdog disable`, which kills the whole watchdog).
|
|
170
|
+
*/
|
|
171
|
+
export declare function setWatchdogRotateEnabled(on: boolean): void;
|
|
172
|
+
export interface RotateGateResult {
|
|
173
|
+
/** True when at least one harness has a healthy account to rotate INTO. */
|
|
174
|
+
healthy: boolean;
|
|
175
|
+
/** Earliest future window reset across all candidates, when any snapshot carries one. */
|
|
176
|
+
resetsAtMs?: number;
|
|
177
|
+
/** Human detail for the skip event (the zero-healthy error text). */
|
|
178
|
+
detail: string;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The first-party health gate: run the SAME selection `agents run auto` would —
|
|
182
|
+
* collectHarnessCandidates over every installed harness, pickHarnessWeighted.
|
|
183
|
+
* Zero healthy → the caller suppresses rotation until earliestResetAcross (or
|
|
184
|
+
* the parsed tail reset, or the default cooldown) and leaves the terminal alone.
|
|
185
|
+
* No `agents view` subprocess, no Keychain probe: collection is cache-only
|
|
186
|
+
* (collectRunCandidates reads daemon-written snapshots, readOnly).
|
|
187
|
+
*/
|
|
188
|
+
export declare function defaultRotateGate(): Promise<RotateGateResult>;
|
|
189
|
+
/**
|
|
190
|
+
* Default TUI-liveness probe for the relaunched session: the new session's
|
|
191
|
+
* transcript resolves under any known harness layout. `--session-id` is honored
|
|
192
|
+
* on a claude pick; for other harnesses the runner's readiness check ALSO
|
|
193
|
+
* accepts a fresh active session (started after the rotate began), so a codex
|
|
194
|
+
* pick with an unknown id is still detected.
|
|
195
|
+
*/
|
|
196
|
+
export declare function defaultRotateTranscriptLive(newSessionId: string): boolean;
|
|
197
|
+
/**
|
|
198
|
+
* The readiness FALLBACK correlation. A fresh active session counts as the
|
|
199
|
+
* relaunched TUI only when ALL of these hold:
|
|
200
|
+
* - it is not the old session and started at/after the rotate began;
|
|
201
|
+
* - it runs in the SAME cwd (trailing-slash normalized); and
|
|
202
|
+
* - it runs on the SAME machine (provenance host = os.hostname(), the same
|
|
203
|
+
* field provenance.ts populates).
|
|
204
|
+
* An unrelated fresh session — another project, another host, a remote
|
|
205
|
+
* teammate — must NEVER satisfy readiness: on a busy fleet box an
|
|
206
|
+
* uncorrelated "any new session" match fires on the first sweep regardless of
|
|
207
|
+
* whether the relaunch came up, and when `agents run auto` failed loud after
|
|
208
|
+
* the gate that types the replay into a bare shell. When the state lacks cwd
|
|
209
|
+
* or host the fallback cannot correlate and only the transcript probe counts.
|
|
210
|
+
*/
|
|
211
|
+
export declare function isCorrelatedRelaunch(state: RotateState, s: ActiveSession): boolean;
|
|
212
|
+
/**
|
|
213
|
+
* The default TUI-liveness probe. The new-session-id transcript is PRIMARY (a
|
|
214
|
+
* claude pick honors `--session-id`); the correlated fresh-session fallback
|
|
215
|
+
* (isCorrelatedRelaunch) covers non-claude picks whose id we can't know a
|
|
216
|
+
* priori.
|
|
217
|
+
*/
|
|
218
|
+
export declare function defaultTuiLiveFor(state: RotateState, sessions: ActiveSession[]): boolean;
|