@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,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
|
+
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
|
|
23
23
|
* - last-tick.json — the full outcome list from the most recent tick.
|
|
24
24
|
* - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
|
|
25
|
+
* - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
|
|
26
|
+
* - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
|
|
25
27
|
*
|
|
26
28
|
* The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
|
|
27
29
|
* re-implemented; the runner only supplies its I/O (sessions, tails, clock,
|
|
@@ -34,6 +36,7 @@ import { type InjectRail } from '../terminal/resolve.js';
|
|
|
34
36
|
import { type InjectResult, type InjectTarget } from '../terminal/inject.js';
|
|
35
37
|
import { type StallStatus, type WatchdogCandidate } from './watchdog.js';
|
|
36
38
|
import { type OpenBlock } from '../feed.js';
|
|
39
|
+
import { type RotateGateResult, type RotatePhase, type RotateState } from './rotate.js';
|
|
37
40
|
/** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
|
|
38
41
|
export type WatchdogPolicy = 'off' | 'keep' | 'handsoff';
|
|
39
42
|
/** Stall / cooldown / dormant thresholds (ms). Defaults mirror read.ts. */
|
|
@@ -92,9 +95,34 @@ export interface WatchdogTickOptions {
|
|
|
92
95
|
/** Inject primitive. Default injectIntoTerminal — tests capture the resolved target. */
|
|
93
96
|
injectFn?: (target: InjectTarget, text: string, opts: {
|
|
94
97
|
dryRun?: boolean;
|
|
98
|
+
enter?: boolean;
|
|
95
99
|
}) => Promise<InjectResult>;
|
|
96
100
|
/** Override the canonical watchdog.log path (tests point at a tmp file). */
|
|
97
101
|
logPath?: string;
|
|
102
|
+
/**
|
|
103
|
+
* In-place rotate of rate-limited sessions. Default = `watchdog.rotate` in
|
|
104
|
+
* agents.yaml (on). Only acts when `nudge` is also set (a dry tick never
|
|
105
|
+
* rotates).
|
|
106
|
+
*/
|
|
107
|
+
rotate?: boolean;
|
|
108
|
+
/** Bounded wait for the relaunched TUI to come live. Default 60s. */
|
|
109
|
+
rotateReadinessMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* First-party health gate run BEFORE rotating. Default defaultRotateGate()
|
|
112
|
+
* (collectHarnessCandidates + pickHarnessWeighted — the same selection
|
|
113
|
+
* `agents run auto` makes). Tests inject a synthetic verdict.
|
|
114
|
+
*/
|
|
115
|
+
rotateGate?: () => Promise<RotateGateResult>;
|
|
116
|
+
/** New session id for the relaunch. Default crypto.randomUUID(). Tests pin it. */
|
|
117
|
+
newSessionIdFor?: () => string;
|
|
118
|
+
/**
|
|
119
|
+
* Readiness probe: is the relaunched TUI live? Default: a transcript for the
|
|
120
|
+
* new session id resolves under a known harness layout, OR a fresh active
|
|
121
|
+
* session (started after the rotate began) exists in this tick's scan.
|
|
122
|
+
*/
|
|
123
|
+
tuiLiveFor?: (state: RotateState, sessions: ActiveSession[]) => boolean;
|
|
124
|
+
/** Delay between exit-sequence keystrokes. Default 300ms; tests set 0. */
|
|
125
|
+
rotateKeyDelayMs?: number;
|
|
98
126
|
}
|
|
99
127
|
/** The smart brain seam: a stalled candidate in, a nudge decision out. */
|
|
100
128
|
export type SmartDecider = (session: ActiveSession, candidate: WatchdogCandidate) => Promise<NudgeDecision>;
|
|
@@ -110,8 +138,10 @@ export interface SessionOutcome {
|
|
|
110
138
|
/** stalled duration (ms), when stalled. */
|
|
111
139
|
stalledForMs?: number;
|
|
112
140
|
policy: WatchdogPolicy;
|
|
113
|
-
decision: 'nudge' | 'skip';
|
|
141
|
+
decision: 'nudge' | 'skip' | 'rotate';
|
|
114
142
|
reason: string;
|
|
143
|
+
/** The rotate machine's phase after this tick (decision === 'rotate'). */
|
|
144
|
+
rotatePhase?: RotatePhase;
|
|
115
145
|
/** The resolved rail, when delivered by injecting into a terminal split. */
|
|
116
146
|
rail?: InjectRail;
|
|
117
147
|
/** How the nudge was (or would be) delivered: inject | mailbox | resume. */
|
|
@@ -137,6 +167,8 @@ export interface WatchdogTickResult {
|
|
|
137
167
|
nudged: number;
|
|
138
168
|
unaddressable: number;
|
|
139
169
|
skipped: number;
|
|
170
|
+
/** Sessions the tick moved through the rotate machine (any phase). */
|
|
171
|
+
rotating: number;
|
|
140
172
|
};
|
|
141
173
|
/**
|
|
142
174
|
* RUSH-2007 Layer C: per-session presence reconciled from this tick's active
|