@phnx-labs/agents-cli 1.22.1 → 1.22.3

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.
@@ -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: a missing/locked
86
- * bundle should not block unrelated agent runs, but an already-unlocked bundle
87
- * or injected token lets ephemeral agents publish with no local setup. */
88
- export function shareRuntimeEnv(opts = {}) {
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: opts.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;
@@ -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.
@@ -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;
@@ -0,0 +1,378 @@
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 * as fs from 'fs';
31
+ import * as path from 'path';
32
+ import { readMeta, writeMeta } from '../state.js';
33
+ import { collectHarnessCandidates, classifyHarnessCandidates, pickHarnessWeighted, earliestResetAcross, formatNoHealthyHarnessError, } from '../rotate.js';
34
+ import { resolveWatchdogSessionPath } from './read.js';
35
+ // --- detection ---------------------------------------------------------------
36
+ /**
37
+ * Agent-reported hard-limit texts, matched against a session transcript tail.
38
+ * Ported verbatim from apps/factory/src/core/autoRotate.ts RATE_LIMIT_PATTERNS —
39
+ * kept specific on purpose: a transcript carries prose, so a loose "rate limit"
40
+ * match would rotate terminals whose agent merely DISCUSSED limits. The first
41
+ * two patterns cover the weekly/session variants, including claude's
42
+ * "You've hit your weekly limit · resets <time>" form.
43
+ */
44
+ export const ROTATE_LIMIT_PATTERNS = [
45
+ /you'?ve hit your [\w-]*\s?limit/i,
46
+ /hit your (weekly|daily|usage|session) limit/i,
47
+ /usage limit (has been )?(reached|exceeded)/i,
48
+ /rate limit (reached|exceeded)/i,
49
+ /out of (credits|extra usage)/i,
50
+ ];
51
+ /**
52
+ * Classify a transcript tail for the rotate decision: does it show a hard
53
+ * account limit (rotate this session) or not (leave it to the nudge path)?
54
+ * Unlike the retired extension path there is NO `no healthy` tail parsing here —
55
+ * the health gate is a first-party function call (defaultRotateGate), not a
56
+ * cross-package string contract.
57
+ */
58
+ export function classifyTailForRotate(tailLines, nowMs) {
59
+ if (tailLines.length === 0)
60
+ return { kind: 'none' };
61
+ const tail = tailLines.join('\n');
62
+ if (ROTATE_LIMIT_PATTERNS.some((p) => p.test(tail))) {
63
+ return { kind: 'rate_limited', resetsAtMs: parseRotateResetMs(tail, nowMs) };
64
+ }
65
+ return { kind: 'none' };
66
+ }
67
+ /**
68
+ * Parse the `resets <time>` clause of a limit line into an epoch-ms horizon.
69
+ * Ported from apps/factory/src/core/autoRotate.ts parseResetTimeMs (behavior
70
+ * verbatim): the ISO form (milliseconds + Z) is matched EXPLICITLY and first —
71
+ * a generic capture stops at the milliseconds dot and drops the Z, which makes
72
+ * Date.parse read LOCAL time (the suppression would end hours off). Time-of-day
73
+ * forms like `7am` / `7:30pm` with an optional `(Area/City)` IANA zone cover
74
+ * claude's own limit text. Returns undefined when no usable reset is present or
75
+ * the parsed time is already past (caller falls back to its default cooldown).
76
+ */
77
+ export function parseRotateResetMs(text, nowMs) {
78
+ const iso = /resets\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)\b/i.exec(text);
79
+ if (iso) {
80
+ const parsedIso = Date.parse(iso[1]);
81
+ return Number.isNaN(parsedIso) || parsedIso <= nowMs ? undefined : parsedIso;
82
+ }
83
+ const m = /resets\s+([^.;!\n]+)/i.exec(text);
84
+ if (!m)
85
+ return undefined;
86
+ const segment = m[1].trim();
87
+ const timeZone = /\(([A-Za-z_]+\/[A-Za-z_]+)\)/.exec(segment)?.[1];
88
+ const timePart = segment.replace(/\([A-Za-z_]+\/[A-Za-z_]+\)/, '').trim();
89
+ const parsed = Date.parse(timePart);
90
+ if (!Number.isNaN(parsed)) {
91
+ return parsed > nowMs ? parsed : undefined;
92
+ }
93
+ const t = /(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i.exec(timePart);
94
+ if (!t)
95
+ return undefined;
96
+ let hour = parseInt(t[1], 10) % 12;
97
+ if (t[3].toLowerCase() === 'pm')
98
+ hour += 12;
99
+ const minute = t[2] ? parseInt(t[2], 10) : 0;
100
+ return nextOccurrenceMs(hour, minute, timeZone, nowMs);
101
+ }
102
+ /** Next wall-clock occurrence of hour:minute in the given zone after nowMs. */
103
+ function nextOccurrenceMs(hour, minute, timeZone, nowMs) {
104
+ try {
105
+ if (!timeZone) {
106
+ const d = new Date(nowMs);
107
+ d.setHours(hour, minute, 0, 0);
108
+ if (d.getTime() <= nowMs)
109
+ d.setDate(d.getDate() + 1);
110
+ return d.getTime();
111
+ }
112
+ // Wall-clock "now" in the target zone, to minute precision — close enough
113
+ // for a cooldown horizon.
114
+ const parts = new Intl.DateTimeFormat('en-US', {
115
+ timeZone,
116
+ hour12: false,
117
+ year: 'numeric',
118
+ month: '2-digit',
119
+ day: '2-digit',
120
+ hour: '2-digit',
121
+ minute: '2-digit',
122
+ }).formatToParts(new Date(nowMs));
123
+ const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
124
+ const year = get('year');
125
+ const month = get('month') - 1;
126
+ const day = get('day');
127
+ const wallNowMs = Date.UTC(year, month, day, get('hour') % 24, get('minute'));
128
+ const offsetMs = wallNowMs - nowMs;
129
+ let candidate = Date.UTC(year, month, day, hour, minute) - offsetMs;
130
+ if (candidate <= nowMs)
131
+ candidate += 24 * 60 * 60 * 1000;
132
+ return candidate;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ // --- exit sequences ------------------------------------------------------------
139
+ /**
140
+ * Clean-exit key sequences per harness, ported verbatim from apps/factory
141
+ * prewarm.ts PREWARM_CONFIGS. Injected as RAW BYTES with no trailing Enter — a
142
+ * literal \x03 written to the pty IS Ctrl+C (SIGINT), \x1b IS Esc. claude's Ink
143
+ * TUI needs the Esc first to leave any open mode before the interrupt pair.
144
+ */
145
+ export const ROTATE_EXIT_SEQUENCES = {
146
+ claude: ['\x1b', '\x03', '\x03'], // Esc, Ctrl+C, Ctrl+C (Esc first for Claude)
147
+ codex: ['\x03', '\x03'], // Ctrl+C twice
148
+ gemini: ['\x03', '\x03'],
149
+ cursor: ['\x03', '\x03'],
150
+ opencode: ['\x03', '\x03'],
151
+ };
152
+ /** Unknown harnesses get the common denominator: Ctrl+C twice. */
153
+ export const DEFAULT_ROTATE_EXIT_SEQUENCE = ['\x03', '\x03'];
154
+ export function exitSequenceFor(agent) {
155
+ return ROTATE_EXIT_SEQUENCES[agent] ?? DEFAULT_ROTATE_EXIT_SEQUENCE;
156
+ }
157
+ // --- launch + replay text ------------------------------------------------------
158
+ /**
159
+ * The rotate relaunch, typed into the same tab: full auto — the CLI resolves
160
+ * host (affinity) → harness (cross-harness headroom) → account (balanced) and
161
+ * exits nonzero when every layer is exhausted. Ported from apps/factory
162
+ * autoRotate.ts buildAutoRotateLaunchCommand. A terminal on a REMOTE device
163
+ * rotates ON that device (`--host`); a local terminal omits it. `--session-id`
164
+ * is honored only when the CLI picks claude (existing claude-only semantics)
165
+ * and ignored otherwise — passing it unconditionally keeps the terminal's
166
+ * AGENT_SESSION_ID aligned with the session Claude actually creates.
167
+ */
168
+ export function buildRotateLaunchCommand(opts) {
169
+ let cmd = 'agents run auto --interactive';
170
+ if (opts.host) {
171
+ cmd += ` --host ${shellQuoteHost(opts.host)}`;
172
+ }
173
+ cmd += ` --session-id ${opts.sessionId}`;
174
+ return cmd;
175
+ }
176
+ /** Single-quote a device name so it can never break out of the built command. */
177
+ function shellQuoteHost(value) {
178
+ return `'${value.replace(/'/g, `'\\''`)}'`;
179
+ }
180
+ /**
181
+ * The harness-agnostic replay injected once the new TUI is live: load the OLD
182
+ * session's transcript, assess, continue. This is the same instruction shape
183
+ * the CLI's own `continue` flow uses.
184
+ */
185
+ export function buildRotateReplayText(oldSessionId) {
186
+ return (`Resume previous work by loading session ${oldSessionId}. ` +
187
+ `Run \`agents sessions ${oldSessionId}\` to load the transcript, assess current state, then continue working.`);
188
+ }
189
+ /** Bounded wait for the relaunched TUI to come live (readiness). */
190
+ export const DEFAULT_ROTATE_READINESS_MS = 60_000;
191
+ /** Zero-healthy skip cooldown when neither the gate nor the tail carries a reset. */
192
+ export const DEFAULT_ROTATE_SKIP_COOLDOWN_MS = 30 * 60_000;
193
+ /** Retry cooldown after a FAILED rotate — honored at begin via the state file. */
194
+ export const DEFAULT_ROTATE_FAILED_COOLDOWN_MS = 15 * 60_000;
195
+ function rotateDir(dir) {
196
+ return path.join(dir, 'rotate');
197
+ }
198
+ export function rotateStatePath(dir, sessionId) {
199
+ return path.join(rotateDir(dir), `${sessionId}.json`);
200
+ }
201
+ export function readRotateState(dir, sessionId) {
202
+ try {
203
+ const parsed = JSON.parse(fs.readFileSync(rotateStatePath(dir, sessionId), 'utf8'));
204
+ return parsed && typeof parsed.sessionId === 'string' && typeof parsed.phase === 'string' ? parsed : null;
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ export function writeRotateState(dir, state) {
211
+ try {
212
+ fs.mkdirSync(rotateDir(dir), { recursive: true });
213
+ fs.writeFileSync(rotateStatePath(dir, state.sessionId), JSON.stringify(state, null, 2));
214
+ }
215
+ catch {
216
+ /* best-effort: the tray tolerates a missing/partial state file */
217
+ }
218
+ }
219
+ export function clearRotateState(dir, sessionId) {
220
+ try {
221
+ fs.unlinkSync(rotateStatePath(dir, sessionId));
222
+ }
223
+ catch {
224
+ /* already gone */
225
+ }
226
+ }
227
+ /** A phase the machine still has work to do in (done/failed are terminal). */
228
+ export function isInflightPhase(phase) {
229
+ return phase !== 'done' && phase !== 'failed';
230
+ }
231
+ /** Every persisted rotate state (any phase) — for `watchdog status`. */
232
+ export function listRotateStates(dir) {
233
+ let files;
234
+ try {
235
+ files = fs.readdirSync(rotateDir(dir)).filter((f) => f.endsWith('.json'));
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ const out = [];
241
+ for (const f of files) {
242
+ const s = readRotateState(dir, f.slice(0, -'.json'.length));
243
+ if (s)
244
+ out.push(s);
245
+ }
246
+ return out;
247
+ }
248
+ /** In-flight rotates only — the set a tick's sweep must advance. */
249
+ export function listInflightRotates(dir) {
250
+ return listRotateStates(dir).filter((s) => isInflightPhase(s.phase));
251
+ }
252
+ // --- zero-healthy skip ledger ----------------------------------------------------
253
+ /**
254
+ * One `rotate` skip event per cooldown window, tracked as
255
+ * <watchdog-state>/rotate-skips.json: { [sessionId]: suppressUntilMs }. A skip
256
+ * inside the window logs nothing and touches nothing.
257
+ */
258
+ export function readRotateSkipLedger(dir) {
259
+ try {
260
+ return JSON.parse(fs.readFileSync(path.join(dir, 'rotate-skips.json'), 'utf8'));
261
+ }
262
+ catch {
263
+ return {};
264
+ }
265
+ }
266
+ /** True when a skip for this session is OUTSIDE its suppression window (log it). */
267
+ export function shouldLogRotateSkip(dir, sessionId, nowMs) {
268
+ return (readRotateSkipLedger(dir)[sessionId] ?? 0) <= nowMs;
269
+ }
270
+ /** Suppress further skip events for this session until suppressUntilMs. */
271
+ export function recordRotateSkip(dir, sessionId, suppressUntilMs) {
272
+ try {
273
+ const ledger = readRotateSkipLedger(dir);
274
+ ledger[sessionId] = suppressUntilMs;
275
+ fs.writeFileSync(path.join(dir, 'rotate-skips.json'), JSON.stringify(ledger, null, 2));
276
+ }
277
+ catch {
278
+ /* best-effort */
279
+ }
280
+ }
281
+ // --- config ---------------------------------------------------------------------
282
+ /**
283
+ * `watchdog.rotate` in agents.yaml (default ON — it is safe now: the health gate
284
+ * is first-party and the readiness wait is bounded). Read fresh per tick so a
285
+ * flip mid-run is honored on the next pass.
286
+ */
287
+ export function isWatchdogRotateEnabled() {
288
+ return readMeta().watchdog?.rotate !== 'off';
289
+ }
290
+ /**
291
+ * Persist `watchdog.rotate: on|off`. Called by the `agents watchdog rotate
292
+ * on|off` subcommand (commands/watchdog.ts) — the rotate-only switch the
293
+ * Factory migration uses so a user who opted out of autoRotate keeps nudging
294
+ * (rather than `agents watchdog disable`, which kills the whole watchdog).
295
+ */
296
+ export function setWatchdogRotateEnabled(on) {
297
+ const meta = readMeta();
298
+ meta.watchdog = { ...(meta.watchdog ?? {}), rotate: on ? 'on' : 'off' };
299
+ writeMeta(meta);
300
+ }
301
+ /**
302
+ * The first-party health gate: run the SAME selection `agents run auto` would —
303
+ * collectHarnessCandidates over every installed harness, pickHarnessWeighted.
304
+ * Zero healthy → the caller suppresses rotation until earliestResetAcross (or
305
+ * the parsed tail reset, or the default cooldown) and leaves the terminal alone.
306
+ * No `agents view` subprocess, no Keychain probe: collection is cache-only
307
+ * (collectRunCandidates reads daemon-written snapshots, readOnly).
308
+ */
309
+ export async function defaultRotateGate() {
310
+ const byHarness = await collectHarnessCandidates();
311
+ const pick = pickHarnessWeighted(byHarness);
312
+ if (pick) {
313
+ return { healthy: true, detail: `picked ${pick.picked.agent}` };
314
+ }
315
+ const all = [...byHarness.values()].flat();
316
+ const reset = earliestResetAcross(all);
317
+ return {
318
+ healthy: false,
319
+ resetsAtMs: reset?.getTime(),
320
+ detail: formatNoHealthyHarnessError(classifyHarnessCandidates(byHarness)),
321
+ };
322
+ }
323
+ // --- readiness --------------------------------------------------------------------
324
+ /** Transcript layouts to probe for the new session (mirrors read.ts's table). */
325
+ const ROTATE_TRANSCRIPT_AGENTS = ['claude', 'codex', 'gemini', 'droid'];
326
+ /**
327
+ * Default TUI-liveness probe for the relaunched session: the new session's
328
+ * transcript resolves under any known harness layout. `--session-id` is honored
329
+ * on a claude pick; for other harnesses the runner's readiness check ALSO
330
+ * accepts a fresh active session (started after the rotate began), so a codex
331
+ * pick with an unknown id is still detected.
332
+ */
333
+ export function defaultRotateTranscriptLive(newSessionId) {
334
+ return ROTATE_TRANSCRIPT_AGENTS.some((agent) => resolveWatchdogSessionPath(newSessionId, agent) !== undefined);
335
+ }
336
+ /** Strip trailing slashes so `/repo` and `/repo/` correlate. */
337
+ function normalizeCwd(cwd) {
338
+ if (!cwd)
339
+ return undefined;
340
+ const n = cwd.replace(/\/+$/, '');
341
+ return n === '' ? '/' : n;
342
+ }
343
+ /**
344
+ * The readiness FALLBACK correlation. A fresh active session counts as the
345
+ * relaunched TUI only when ALL of these hold:
346
+ * - it is not the old session and started at/after the rotate began;
347
+ * - it runs in the SAME cwd (trailing-slash normalized); and
348
+ * - it runs on the SAME machine (provenance host = os.hostname(), the same
349
+ * field provenance.ts populates).
350
+ * An unrelated fresh session — another project, another host, a remote
351
+ * teammate — must NEVER satisfy readiness: on a busy fleet box an
352
+ * uncorrelated "any new session" match fires on the first sweep regardless of
353
+ * whether the relaunch came up, and when `agents run auto` failed loud after
354
+ * the gate that types the replay into a bare shell. When the state lacks cwd
355
+ * or host the fallback cannot correlate and only the transcript probe counts.
356
+ */
357
+ export function isCorrelatedRelaunch(state, s) {
358
+ if (!s.sessionId || s.sessionId === state.sessionId)
359
+ return false;
360
+ if ((s.startedAtMs ?? 0) < state.startedAtMs)
361
+ return false;
362
+ const cwd = normalizeCwd(state.cwd);
363
+ const host = state.machineHost;
364
+ if (!cwd || !host)
365
+ return false;
366
+ return normalizeCwd(s.cwd) === cwd && s.provenance?.host === host;
367
+ }
368
+ /**
369
+ * The default TUI-liveness probe. The new-session-id transcript is PRIMARY (a
370
+ * claude pick honors `--session-id`); the correlated fresh-session fallback
371
+ * (isCorrelatedRelaunch) covers non-claude picks whose id we can't know a
372
+ * priori.
373
+ */
374
+ export function defaultTuiLiveFor(state, sessions) {
375
+ if (defaultRotateTranscriptLive(state.newSessionId))
376
+ return true;
377
+ return sessions.some((s) => isCorrelatedRelaunch(state, s));
378
+ }