@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.
@@ -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
@@ -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,
@@ -30,6 +32,7 @@
30
32
  */
31
33
  import * as fs from 'fs';
32
34
  import * as path from 'path';
35
+ import * as crypto from 'crypto';
33
36
  import { getActiveSessions } from '../session/active.js';
34
37
  import { reconcilePresence, loadPresence, savePresence, observedFromActive, } from '../session/presence.js';
35
38
  import { resolveInjectTargetForSession, } from '../terminal/resolve.js';
@@ -44,6 +47,7 @@ import { mailboxIdForActiveSession } from '../mailbox-target.js';
44
47
  import { readBlock, blockIdForSession } from '../feed.js';
45
48
  import { summarizeWatchdogTail } from './watchdogTail.js';
46
49
  import { appendWatchdogEvents } from './log.js';
50
+ import { buildRotateLaunchCommand, buildRotateReplayText, classifyTailForRotate, defaultRotateGate, defaultTuiLiveFor, exitSequenceFor, isInflightPhase, isWatchdogRotateEnabled, listInflightRotates, readRotateState, recordRotateSkip, shouldLogRotateSkip, writeRotateState, DEFAULT_ROTATE_READINESS_MS, DEFAULT_ROTATE_SKIP_COOLDOWN_MS, DEFAULT_ROTATE_FAILED_COOLDOWN_MS, } from './rotate.js';
47
51
  export const DEFAULT_THRESHOLDS = {
48
52
  stallMs: WATCHDOG_STALL_MS,
49
53
  cooldownMs: WATCHDOG_COOLDOWN_MS,
@@ -250,6 +254,87 @@ async function deliverViaResume(session, text) {
250
254
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
251
255
  }
252
256
  }
257
+ /** The one-line reason a rotate outcome carries. */
258
+ function rotateOutcomeReason(s) {
259
+ switch (s.phase) {
260
+ case 'awaiting-tui':
261
+ return `rotate in flight — awaiting new session ${s.newSessionId} TUI (deadline ${new Date(s.deadlineMs).toISOString()})`;
262
+ case 'done':
263
+ return `rotated → ${s.newSessionId}; replayed resume`;
264
+ case 'failed':
265
+ return `rotate failed: ${s.error ?? 'unknown'}`;
266
+ default:
267
+ return `rotate in flight (${s.phase})`;
268
+ }
269
+ }
270
+ /**
271
+ * Advance ONE in-flight rotate by a single tick. Only `awaiting-tui` normally
272
+ * spans ticks (the exit sequence kills the old session, so the machine resumes
273
+ * here on the next pass); `exiting`/`launching`/`replaying` persisted on disk
274
+ * are crash residue — the first two fall through to the readiness probe (the
275
+ * launch may or may not have landed; the probe/deadline decides), a `replaying`
276
+ * residue re-delivers the replay. On the readiness deadline the session is
277
+ * FAILED + flagged and the machine stops — never blind-type into a dead shell.
278
+ */
279
+ async function advanceRotate(state, deps) {
280
+ let s = state;
281
+ const fail = (error) => {
282
+ s = {
283
+ ...s, phase: 'failed', error, updatedAtMs: deps.nowMs,
284
+ // Failed-rotate retry cooldown (honored at begin): without it a session
285
+ // whose old TUI ignored the exit sequence re-begins and deadline-fails
286
+ // every tick forever.
287
+ suppressUntilMs: deps.nowMs + DEFAULT_ROTATE_FAILED_COOLDOWN_MS,
288
+ };
289
+ writeRotateState(deps.dir, s);
290
+ deps.flags[s.sessionId] = { reason: `rotate failed: ${error}`, host: s.host, atMs: deps.nowMs };
291
+ deps.logEvents.push({
292
+ ts: deps.nowMs, kind: 'rotate', terminalId: s.sessionId, agentType: s.agent,
293
+ message: `rotate failed: ${s.sessionId} — ${error}`,
294
+ });
295
+ return s;
296
+ };
297
+ if (s.phase === 'exiting' || s.phase === 'launching') {
298
+ s = { ...s, phase: 'awaiting-tui', updatedAtMs: deps.nowMs };
299
+ writeRotateState(deps.dir, s);
300
+ }
301
+ if (s.phase === 'awaiting-tui') {
302
+ if (!deps.tuiLiveFor(s, deps.sessions)) {
303
+ if (deps.nowMs > s.deadlineMs) {
304
+ return fail(`new session ${s.newSessionId} TUI not live within the readiness budget — nothing was typed ` +
305
+ `into a possibly-dead shell; the terminal may sit at a BARE SHELL now: relaunch manually with ` +
306
+ `\`agents run auto\` (there is no automatic recovery)`);
307
+ }
308
+ return s; // still inside the bounded wait
309
+ }
310
+ s = { ...s, phase: 'replaying', updatedAtMs: deps.nowMs };
311
+ writeRotateState(deps.dir, s);
312
+ }
313
+ if (s.phase === 'replaying') {
314
+ if (!deps.mayInject)
315
+ return s; // dry tick: hold, never inject
316
+ let ok = true;
317
+ let error;
318
+ try {
319
+ const r = await deps.injectFn(s.target, buildRotateReplayText(s.sessionId), { dryRun: deps.injectDryRun });
320
+ ok = r.ok;
321
+ error = r.error;
322
+ }
323
+ catch (err) {
324
+ ok = false;
325
+ error = err instanceof Error ? err.message : String(err);
326
+ }
327
+ if (!ok)
328
+ return fail(`replay inject failed: ${error ?? 'unknown error'}`);
329
+ s = { ...s, phase: 'done', updatedAtMs: deps.nowMs };
330
+ writeRotateState(deps.dir, s);
331
+ deps.logEvents.push({
332
+ ts: deps.nowMs, kind: 'rotate', terminalId: s.sessionId, agentType: s.agent,
333
+ message: `rotated ${s.sessionId} → ${s.newSessionId}; replayed resume`,
334
+ });
335
+ }
336
+ return s;
337
+ }
253
338
  // --- the tick ---------------------------------------------------------------
254
339
  /**
255
340
  * Run ONE watchdog pass. Returns a structured outcome per live session; injects
@@ -268,6 +353,20 @@ export async function runWatchdogTick(opts = {}) {
268
353
  const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
269
354
  const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
270
355
  const injectFn = opts.injectFn ?? injectIntoTerminal;
356
+ // Rotate seams (watchdog/rotate.ts). The config is read fresh per tick
357
+ // (readMeta is mtime-cached), so a `watchdog.rotate` flip is honored on the
358
+ // next pass. The default readiness probe (defaultTuiLiveFor) treats the
359
+ // new-session-id transcript as PRIMARY (a claude pick honors --session-id)
360
+ // with a CORRELATED fallback: a fresh active session counts only when it
361
+ // started after the rotate began AND shares the old session's cwd and
362
+ // machine — an unrelated fresh session on a busy box must never satisfy it
363
+ // (that would type the replay into a bare shell when the relaunch failed).
364
+ const rotateEnabled = opts.rotate ?? isWatchdogRotateEnabled();
365
+ const rotateGate = opts.rotateGate ?? defaultRotateGate;
366
+ const newSessionIdFor = opts.newSessionIdFor ?? (() => crypto.randomUUID());
367
+ const rotateReadinessMs = opts.rotateReadinessMs ?? DEFAULT_ROTATE_READINESS_MS;
368
+ const rotateKeyDelayMs = opts.rotateKeyDelayMs ?? 300;
369
+ const tuiLiveFor = opts.tuiLiveFor ?? defaultTuiLiveFor;
271
370
  const sessions = opts.sessions ?? (await getActiveSessions());
272
371
  // RUSH-2007 Layer C — reconcile per-session presence from this tick's active
273
372
  // scan and persist it. Additive: it derives connect/disconnect from what this
@@ -291,6 +390,17 @@ export async function runWatchdogTick(opts = {}) {
291
390
  const outcomes = [];
292
391
  const logEvents = [];
293
392
  const viaLabel = (plan) => plan.via === 'inject' ? `inject (${plan.rail})` : plan.via;
393
+ // Rotate bookkeeping for this tick: ids the in-loop path already advanced
394
+ // (the post-loop sweep handles the rest — a session whose exit sequence
395
+ // killed it drops out of the active list, so only the sweep can finish it).
396
+ const advancedRotates = new Set();
397
+ const rotateDeps = {
398
+ dir, nowMs, sessions,
399
+ mayInject: opts.nudge === true,
400
+ injectFn, tuiLiveFor,
401
+ injectDryRun: opts.injectDryRun,
402
+ logEvents, flags,
403
+ };
294
404
  for (const session of sessions) {
295
405
  const policy = policyFor(session);
296
406
  const base = {
@@ -349,6 +459,187 @@ export async function runWatchdogTick(opts = {}) {
349
459
  tailLines,
350
460
  stalledForMs: status.stalledForMs,
351
461
  };
462
+ // --- rotate path (watchdog/rotate.ts) -----------------------------------
463
+ // An in-flight rotate OWNS this session: advance the machine, never nudge.
464
+ // A stalled session whose tail shows a HARD LIMIT rotates in place instead
465
+ // of nudging — "Continue." cannot unspend a capped account.
466
+ if (rotateEnabled) {
467
+ const sid = session.sessionId;
468
+ const inflight = readRotateState(dir, sid);
469
+ if (inflight && isInflightPhase(inflight.phase)) {
470
+ const advanced = await advanceRotate(inflight, rotateDeps);
471
+ advancedRotates.add(sid);
472
+ outcomes.push({
473
+ ...base, decision: 'rotate', rotatePhase: advanced.phase,
474
+ reason: rotateOutcomeReason(advanced),
475
+ });
476
+ continue;
477
+ }
478
+ // Failed-rotate retry cooldown: a terminal that already failed (e.g. its
479
+ // old TUI ignored the exit sequence) is not re-entered until the
480
+ // suppression recorded at the failure lapses.
481
+ if (inflight && inflight.phase === 'failed' && (inflight.suppressUntilMs ?? 0) > nowMs) {
482
+ outcomes.push({
483
+ ...base, decision: 'skip', rotatePhase: 'failed',
484
+ reason: `rotate suppressed until ${new Date(inflight.suppressUntilMs).toISOString()} (failed-rotate cooldown)`,
485
+ });
486
+ continue;
487
+ }
488
+ const verdict = classifyTailForRotate(tailLines, nowMs);
489
+ if (verdict.kind === 'rate_limited') {
490
+ // handsoff = detect + flag, but never rotate (mirrors the nudge path).
491
+ if (policy === 'handsoff') {
492
+ flags[sid] = {
493
+ reason: 'handsoff: rate-limited, would rotate in place but policy is hands-off',
494
+ host: session.host,
495
+ atMs: nowMs,
496
+ };
497
+ outcomes.push({
498
+ ...base, decision: 'skip',
499
+ reason: 'handsoff: rate-limited — flagged, not rotated',
500
+ });
501
+ continue;
502
+ }
503
+ // Dry tick (no --nudge): report what WOULD happen, touch nothing.
504
+ if (!opts.nudge) {
505
+ outcomes.push({
506
+ ...base, decision: 'rotate',
507
+ reason: 'rate-limited — would rotate in place via `agents run auto` (dry — pass --nudge)',
508
+ });
509
+ continue;
510
+ }
511
+ // First-party health gate — the SAME selection `agents run auto` would
512
+ // make. Zero healthy → ONE skip event per cooldown window, terminal
513
+ // untouched. Cooldown = earliestResetAcross (gate) → parsed tail reset
514
+ // → 30m default, whichever is known first. A gate THROW degrades to a
515
+ // skip for this session — it must never abort the whole tick (which
516
+ // would skip last-tick.json and every other session's decision).
517
+ let gate;
518
+ try {
519
+ gate = await rotateGate();
520
+ }
521
+ catch (err) {
522
+ const msg = err instanceof Error ? err.message : String(err);
523
+ logEvents.push({
524
+ ts: nowMs, kind: 'error', terminalId: sid, agentType: candidate.agentType,
525
+ message: `rotate gate failed: ${msg}`,
526
+ });
527
+ outcomes.push({
528
+ ...base, decision: 'skip',
529
+ reason: `rate-limited but the rotate health gate failed — skipped this tick: ${msg}`,
530
+ });
531
+ continue;
532
+ }
533
+ if (!gate.healthy) {
534
+ const cooldownMs = gate.resetsAtMs !== undefined ? Math.max(60_000, gate.resetsAtMs - nowMs)
535
+ : verdict.resetsAtMs !== undefined ? Math.max(60_000, verdict.resetsAtMs - nowMs)
536
+ : DEFAULT_ROTATE_SKIP_COOLDOWN_MS;
537
+ const suppressUntilMs = nowMs + cooldownMs;
538
+ if (shouldLogRotateSkip(dir, sid, nowMs)) {
539
+ recordRotateSkip(dir, sid, suppressUntilMs);
540
+ logEvents.push({
541
+ ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
542
+ message: `rotate skipped: no healthy harness — ${gate.detail}; suppressed until ${new Date(suppressUntilMs).toISOString()}`,
543
+ reason: gate.detail,
544
+ });
545
+ }
546
+ outcomes.push({
547
+ ...base, decision: 'skip',
548
+ reason: `rate-limited but no healthy account/harness to rotate into — terminal untouched (suppressed until ${new Date(suppressUntilMs).toISOString()})`,
549
+ });
550
+ continue;
551
+ }
552
+ // The safety gate is the same one the nudge path obeys: an exact
553
+ // addressable rail or an honest flag — never a guessed target.
554
+ const resolution = resolveInjectTargetForSession(session, { allowGhosttyFocus: opts.allowGhosttyFocus });
555
+ if (!resolution.addressable) {
556
+ flags[sid] = { reason: `rotate: ${resolution.reason}`, host: session.host, atMs: nowMs };
557
+ outcomes.push({
558
+ ...base, decision: 'skip', addressable: false,
559
+ reason: `rate-limited but un-addressable — ${resolution.reason}`,
560
+ });
561
+ continue;
562
+ }
563
+ // Begin: persist exiting → inject the per-harness exit sequence →
564
+ // launching → inject `agents run auto` → awaiting-tui (bounded). cwd +
565
+ // machineHost are stored for the readiness fallback's correlation.
566
+ const newSessionId = newSessionIdFor();
567
+ let state = {
568
+ sessionId: sid,
569
+ newSessionId,
570
+ agent: session.kind,
571
+ phase: 'exiting',
572
+ target: resolution.target,
573
+ host: session.provenance?.transport === 'ssh' ? session.provenance.host : undefined,
574
+ cwd: session.cwd,
575
+ machineHost: session.provenance?.host,
576
+ startedAtMs: nowMs,
577
+ updatedAtMs: nowMs,
578
+ deadlineMs: nowMs + rotateReadinessMs,
579
+ };
580
+ writeRotateState(dir, state);
581
+ advancedRotates.add(sid);
582
+ let rotateFailed = null;
583
+ for (const key of exitSequenceFor(session.kind)) {
584
+ try {
585
+ const r = await injectFn(state.target, key, { dryRun: opts.injectDryRun, enter: false });
586
+ if (!r.ok) {
587
+ rotateFailed = `exit sequence inject failed: ${r.error ?? 'unknown error'}`;
588
+ break;
589
+ }
590
+ }
591
+ catch (err) {
592
+ rotateFailed = `exit sequence inject threw: ${err instanceof Error ? err.message : String(err)}`;
593
+ break;
594
+ }
595
+ // A real TUI needs a beat between Esc and the interrupt pair.
596
+ if (rotateKeyDelayMs > 0 && !opts.injectDryRun) {
597
+ await new Promise((resolve) => setTimeout(resolve, rotateKeyDelayMs));
598
+ }
599
+ }
600
+ if (!rotateFailed) {
601
+ state = { ...state, phase: 'launching', updatedAtMs: nowMs };
602
+ writeRotateState(dir, state);
603
+ const launch = buildRotateLaunchCommand({ host: state.host, sessionId: newSessionId });
604
+ try {
605
+ const r = await injectFn(state.target, launch, { dryRun: opts.injectDryRun });
606
+ if (!r.ok)
607
+ rotateFailed = `launch inject failed: ${r.error ?? 'unknown error'}`;
608
+ }
609
+ catch (err) {
610
+ rotateFailed = `launch inject threw: ${err instanceof Error ? err.message : String(err)}`;
611
+ }
612
+ }
613
+ if (rotateFailed) {
614
+ state = {
615
+ ...state, phase: 'failed', error: rotateFailed, updatedAtMs: nowMs,
616
+ suppressUntilMs: nowMs + DEFAULT_ROTATE_FAILED_COOLDOWN_MS,
617
+ };
618
+ writeRotateState(dir, state);
619
+ flags[sid] = { reason: `rotate failed: ${rotateFailed}`, host: session.host, atMs: nowMs };
620
+ logEvents.push({
621
+ ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
622
+ message: `rotate failed: ${sid} — ${rotateFailed}`,
623
+ });
624
+ outcomes.push({
625
+ ...base, decision: 'rotate', rotatePhase: 'failed', addressable: true, rail: resolution.rail,
626
+ reason: `rotate failed: ${rotateFailed}`,
627
+ });
628
+ continue;
629
+ }
630
+ state = { ...state, phase: 'awaiting-tui', updatedAtMs: nowMs };
631
+ writeRotateState(dir, state);
632
+ logEvents.push({
633
+ ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
634
+ message: `rotating ${sid} in place → agents run auto (new session ${newSessionId})`,
635
+ });
636
+ outcomes.push({
637
+ ...base, decision: 'rotate', rotatePhase: 'awaiting-tui', addressable: true, rail: resolution.rail,
638
+ reason: `rotating in place → agents run auto (new session ${newSessionId})`,
639
+ });
640
+ continue;
641
+ }
642
+ }
352
643
  // The brain. The cheap deterministic pre-filter resolves the obvious cases;
353
644
  // parked-on-question and ambiguous stalls ESCALATE to the smart brain. `smart`
354
645
  // forces every stalled candidate through the brain.
@@ -471,6 +762,17 @@ export async function runWatchdogTick(opts = {}) {
471
762
  });
472
763
  }
473
764
  }
765
+ // Advance in-flight rotates the in-loop path did NOT touch — critically the
766
+ // common case where the exit sequence killed the old harness, so the old
767
+ // session dropped out of the active-session list and only this sweep can
768
+ // finish the machine (readiness → replay, or deadline → fail + flag). Runs
769
+ // even when rotate was just disabled so a mid-flight machine is never
770
+ // stranded; a dry tick advances state but never injects (mayInject).
771
+ for (const inflight of listInflightRotates(dir)) {
772
+ if (advancedRotates.has(inflight.sessionId))
773
+ continue;
774
+ await advanceRotate(inflight, rotateDeps);
775
+ }
474
776
  // Persist the cooldown ledger under a lock: fresh-read + merge this tick's
475
777
  // updates + atomic write, so a concurrent tick's timestamps are never lost
476
778
  // (the old unlocked read-at-start / write-at-end was a lost-update race).
@@ -497,6 +799,7 @@ export async function runWatchdogTick(opts = {}) {
497
799
  nudged: outcomes.filter((o) => o.injected).length,
498
800
  unaddressable: outcomes.filter((o) => o.addressable === false).length,
499
801
  skipped: outcomes.filter((o) => o.decision === 'skip').length,
802
+ rotating: outcomes.filter((o) => o.decision === 'rotate').length,
500
803
  };
501
804
  const result = { atMs: nowMs, didNudge: opts.nudge === true, outcomes, counts, presence };
502
805
  writeJsonFile(path.join(dir, 'last-tick.json'), result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.1",
3
+ "version": "1.22.3",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",