@phnx-labs/agents-cli 1.22.46 → 1.22.47
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 +279 -0
- package/LICENSE +102 -182
- package/README.md +31 -18
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +4 -2
- package/dist/commands/accounts.js +56 -12
- package/dist/commands/auth.js +7 -5
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +266 -15
- package/dist/commands/config.js +26 -4
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +55 -38
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/lease.js +3 -3
- package/dist/commands/routines.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +194 -16
- package/dist/commands/teams.js +34 -2
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +18 -31
- package/dist/commands/watchdog.js +9 -14
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +70 -3
- package/dist/lib/accounting/usage.js +235 -63
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +14 -6
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +33 -7
- package/dist/lib/browser/profiles.d.ts +106 -1
- package/dist/lib/browser/profiles.js +300 -17
- package/dist/lib/browser/remote-control.d.ts +26 -2
- package/dist/lib/browser/remote-control.js +33 -3
- package/dist/lib/browser/runtime-state.d.ts +9 -0
- package/dist/lib/browser/runtime-state.js +24 -8
- package/dist/lib/browser/service.d.ts +37 -0
- package/dist/lib/browser/service.js +78 -20
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +17 -7
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon-ticks.js +2 -2
- package/dist/lib/device-config.d.ts +25 -8
- package/dist/lib/device-config.js +90 -17
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +6 -0
- package/dist/lib/devices/stats-cache.js +10 -2
- package/dist/lib/exec.d.ts +5 -3
- package/dist/lib/exec.js +32 -16
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/hooks/cache.js +8 -0
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/passthrough.js +16 -0
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +13 -0
- package/dist/lib/menubar/install-menubar.js +26 -2
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +69 -1
- package/dist/lib/session/db.js +4 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +12 -6
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +6 -1
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.js +5 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +15 -0
- package/dist/lib/session/trajectory-html.js +750 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +277 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +335 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +4 -2
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +173 -27
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +17 -3
- package/dist/lib/usage-refresh.js +57 -11
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/package.json +2 -2
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* floor and ceiling are both 5 minutes.
|
|
20
20
|
* - **Hard hourly cap** (`HOURLY_CALL_CAP`) so a stuck "due" loop cannot
|
|
21
21
|
* hammer an endpoint past ~12 calls/account/hour.
|
|
22
|
-
* - **429 backoff.**
|
|
23
|
-
*
|
|
22
|
+
* - **429 backoff.** An account (or provider-wide scope) under
|
|
23
|
+
* `usageRateLimitedUntil` is skipped — no live fetch or re-armed penalty.
|
|
24
24
|
* - **File-only credentials on the daemon path.** Refresh never opens the
|
|
25
25
|
* ACL-bound macOS keychain item (Touch ID storm). It uses the no-ACL
|
|
26
26
|
* access-token cache / setup-token / `.credentials.json` only
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
* serializes read-modify-write; no lost updates.
|
|
38
38
|
* 3. **macOS keychain ACL / Touch ID** — fileOnly refresh never calls
|
|
39
39
|
* `security find-generic-password` on Claude's ACL item.
|
|
40
|
-
* 4. **
|
|
41
|
-
*
|
|
40
|
+
* 4. **Account 429** — that account is skipped until Retry-After while its
|
|
41
|
+
* siblings continue; a provider-wide penalty still skips all accounts.
|
|
42
42
|
* 5. **Expired access token (no refresh)** — usage path never rotates
|
|
43
43
|
* single-use refresh tokens; counts as `failed`, reschedules 5m later.
|
|
44
44
|
* 6. **No file credential on this host** — account skipped / failed; no
|
|
@@ -71,6 +71,12 @@ export const REFRESH_BURN_DIVISOR = 4;
|
|
|
71
71
|
export const HOURLY_CALL_CAP = 12;
|
|
72
72
|
/** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
|
|
73
73
|
export const USAGE_REFRESH_TICK_MS = 60 * 1000;
|
|
74
|
+
/** Consecutive failed live reads before one broken account is quarantined. */
|
|
75
|
+
export const FAILURE_QUARANTINE_THRESHOLD = 3;
|
|
76
|
+
/** A chronic offender waits this long while healthy siblings keep their cadence. */
|
|
77
|
+
export const FAILURE_QUARANTINE_MS = 30 * 60 * 1000;
|
|
78
|
+
const SKIP_JITTER_MIN_MS = 2_000;
|
|
79
|
+
const SKIP_JITTER_RANGE_MS = 3_001;
|
|
74
80
|
const HOUR_MS = 60 * 60 * 1000;
|
|
75
81
|
/** Test seam for the headroom cache path (see usage.ts `setClaudeUsageCachePathForTest`). */
|
|
76
82
|
let headroomCachePathOverride = null;
|
|
@@ -169,8 +175,47 @@ export function nextHeadroomEntry(prev, snapshot, now) {
|
|
|
169
175
|
nextRefreshAt: now + computeNextRefreshDelayMs(headroom.minutesToLimit),
|
|
170
176
|
callTimestamps,
|
|
171
177
|
computedAt: now,
|
|
178
|
+
consecutiveFailures: snapshot ? 0 : (prev?.consecutiveFailures ?? 0) + 1,
|
|
172
179
|
};
|
|
173
180
|
}
|
|
181
|
+
/** Stable per-account delay in [2s, 5s], used to spread skipped accounts. */
|
|
182
|
+
function skipJitterMs(usageKey) {
|
|
183
|
+
let hash = 0;
|
|
184
|
+
for (const char of usageKey)
|
|
185
|
+
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
186
|
+
return SKIP_JITTER_MIN_MS + (hash % SKIP_JITTER_RANGE_MS);
|
|
187
|
+
}
|
|
188
|
+
function skippedHeadroomEntry(prev, usageKey, now, index) {
|
|
189
|
+
return {
|
|
190
|
+
status: prev?.status ?? null,
|
|
191
|
+
minutesToLimit: prev?.minutesToLimit ?? null,
|
|
192
|
+
sessionUsedPercent: prev?.sessionUsedPercent ?? null,
|
|
193
|
+
capturedAt: prev?.capturedAt ?? null,
|
|
194
|
+
nextRefreshAt: now + (index + 1) * skipJitterMs(usageKey),
|
|
195
|
+
callTimestamps: pruneCallTimestamps(prev?.callTimestamps ?? [], now),
|
|
196
|
+
computedAt: now,
|
|
197
|
+
consecutiveFailures: prev?.consecutiveFailures ?? 0,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function failedHeadroomEntry(prev, now) {
|
|
201
|
+
const next = nextHeadroomEntry(prev, null, now);
|
|
202
|
+
if ((next.consecutiveFailures ?? 0) >= FAILURE_QUARANTINE_THRESHOLD) {
|
|
203
|
+
next.nextRefreshAt = now + FAILURE_QUARANTINE_MS;
|
|
204
|
+
}
|
|
205
|
+
return next;
|
|
206
|
+
}
|
|
207
|
+
/** Cold accounts lead each pass; both cold and cached groups rotate every tick. */
|
|
208
|
+
export function orderUsageAccounts(accounts, cache, tick) {
|
|
209
|
+
const rotate = (group) => {
|
|
210
|
+
if (group.length < 2)
|
|
211
|
+
return group;
|
|
212
|
+
const start = tick % group.length;
|
|
213
|
+
return [...group.slice(start), ...group.slice(0, start)];
|
|
214
|
+
};
|
|
215
|
+
const cold = accounts.filter((account) => cache[account.usageKey] == null);
|
|
216
|
+
const cached = accounts.filter((account) => cache[account.usageKey] != null);
|
|
217
|
+
return [...rotate(cold), ...rotate(cached)];
|
|
218
|
+
}
|
|
174
219
|
/**
|
|
175
220
|
* Enumerate the usage accounts whose credentials live on THIS host — one
|
|
176
221
|
* per unique usage key, deduped to the most-recently-active version (the same
|
|
@@ -217,7 +262,7 @@ export async function buildLocalUsageAccounts() {
|
|
|
217
262
|
}
|
|
218
263
|
/**
|
|
219
264
|
* One refresher tick: for each local account that is due, under its hourly cap,
|
|
220
|
-
* and not
|
|
265
|
+
* and not backed off, live-fetch its usage, update the cache, and
|
|
221
266
|
* reschedule from the new burn projection. Never throws — a single account's
|
|
222
267
|
* failed fetch leaves its cache untouched and counts as `failed`.
|
|
223
268
|
*/
|
|
@@ -230,14 +275,15 @@ export async function runUsageRefresh(deps) {
|
|
|
230
275
|
skippedCap: 0,
|
|
231
276
|
failed: 0,
|
|
232
277
|
};
|
|
233
|
-
const accounts = await deps.listAccounts();
|
|
234
278
|
const cache = readHeadroomCache();
|
|
279
|
+
const accounts = orderUsageAccounts(await deps.listAccounts(), cache, Math.floor(now / USAGE_REFRESH_TICK_MS));
|
|
235
280
|
const updates = {};
|
|
236
|
-
for (const account of accounts) {
|
|
281
|
+
for (const [index, account] of accounts.entries()) {
|
|
237
282
|
const entry = cache[account.usageKey] ?? null;
|
|
238
|
-
// A provider
|
|
283
|
+
// A penalized account/provider is off-limits — poking it re-arms the
|
|
239
284
|
// penalty (the whole reason usage-backoff exists).
|
|
240
|
-
if ((deps.backoffUntil(account.agentId) ?? 0) > now) {
|
|
285
|
+
if ((deps.backoffUntil(account.agentId, account.usageKey) ?? 0) > now) {
|
|
286
|
+
updates[account.usageKey] = skippedHeadroomEntry(entry, account.usageKey, now, index);
|
|
241
287
|
result.skippedBackoff += 1;
|
|
242
288
|
continue;
|
|
243
289
|
}
|
|
@@ -259,12 +305,12 @@ export async function runUsageRefresh(deps) {
|
|
|
259
305
|
// No live snapshot (expired token / fetch miss): don't rewrite the usage
|
|
260
306
|
// cache, but still record the call + reschedule so a broken account
|
|
261
307
|
// isn't retried every tick.
|
|
262
|
-
updates[account.usageKey] =
|
|
308
|
+
updates[account.usageKey] = failedHeadroomEntry(entry, now);
|
|
263
309
|
result.failed += 1;
|
|
264
310
|
}
|
|
265
311
|
}
|
|
266
312
|
catch {
|
|
267
|
-
updates[account.usageKey] =
|
|
313
|
+
updates[account.usageKey] = failedHeadroomEntry(entry, now);
|
|
268
314
|
result.failed += 1;
|
|
269
315
|
}
|
|
270
316
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
|
|
2
2
|
export declare const WATCHDOG_LOG_PATH: string;
|
|
3
3
|
/** Kinds the Factory reader accepts. Keep in lockstep with watchdogLog.ts. */
|
|
4
|
-
export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'rotate' | 'error';
|
|
4
|
+
export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'undelivered' | 'rotate' | 'error';
|
|
5
5
|
/** JSONL row shape — a verbatim replica of the Factory reader's WatchdogEvent. */
|
|
6
6
|
export interface WatchdogEvent {
|
|
7
7
|
ts: number;
|
package/dist/lib/watchdog/log.js
CHANGED
|
@@ -18,7 +18,7 @@ import * as path from 'path';
|
|
|
18
18
|
import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
|
|
19
19
|
/** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
|
|
20
20
|
export const WATCHDOG_LOG_PATH = path.join(os.homedir(), '.agents', '.cache', 'logs', 'watchdog.log');
|
|
21
|
-
const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'rotate', 'error']);
|
|
21
|
+
const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'undelivered', 'rotate', 'error']);
|
|
22
22
|
/** Parse the audit log without letting a partial final append hide valid rows. */
|
|
23
23
|
export function parseWatchdogEvents(text) {
|
|
24
24
|
const events = [];
|
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
* working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
|
|
4
4
|
* the whole loop runs WITHOUT the Swift menu-bar.
|
|
5
5
|
*
|
|
6
|
-
* One tick
|
|
6
|
+
* One tick:
|
|
7
7
|
*
|
|
8
8
|
* getActiveSessions() (session/active.ts)
|
|
9
|
-
* -> classifyTerminal(...)
|
|
10
|
-
* -> readWatchdogTail(...)
|
|
11
|
-
* ->
|
|
9
|
+
* -> classifyTerminal(...) per session (watchdog/watchdog.ts) — which are idle?
|
|
10
|
+
* -> readWatchdogTail(...) for the idle ones (watchdog/read.ts) — task + transcript tail
|
|
11
|
+
* -> the watchdog AGENT judges them ALL at once (watchdog/watchdog-agent.ts)
|
|
12
|
+
* — idle-but-unfinished -> nudge; idle-and-done / needs-human -> skip
|
|
12
13
|
* -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
|
|
13
|
-
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver
|
|
14
|
+
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver into the EXACT split, then CONFIRM
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* There is no heuristic decider — no regex over the tail guessing done-vs-stuck.
|
|
17
|
+
* The agent is the whole decider, given every idle session's task + tail in ONE
|
|
18
|
+
* `agents run --mode plan` call per tick. The safety gate is absolute: a nudge is
|
|
19
|
+
* delivered ONLY when the resolver returns `addressable: true`, and it is booked in
|
|
20
|
+
* the cooldown ledger ONLY when delivery is CONFIRMED. On `addressable: false` the
|
|
21
|
+
* reason is recorded to a state file the menu-bar can surface and the session is
|
|
22
|
+
* SKIPPED — never a guessed / frontmost target.
|
|
19
23
|
*
|
|
20
24
|
* Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
|
|
21
25
|
* - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
|
|
@@ -25,15 +29,16 @@
|
|
|
25
29
|
* - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
|
|
26
30
|
* - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
|
|
27
31
|
*
|
|
28
|
-
* The pure logic (classifyTerminal
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* The pure logic (classifyTerminal) is imported and never re-implemented; the
|
|
33
|
+
* runner only supplies its I/O (sessions, tails, clock, policy, the agent decider,
|
|
34
|
+
* injection) — each an injectable seam so runner.test.ts drives real synthetic
|
|
35
|
+
* sessions without a live terminal or a real `agents run`.
|
|
32
36
|
*/
|
|
33
37
|
import type { ActiveSession } from '../session/active.js';
|
|
34
38
|
import { type PresenceTransition } from '../session/presence.js';
|
|
35
39
|
import { type InjectRail, type InjectResult, type InjectTarget } from '../terminal/index.js';
|
|
36
40
|
import { type StallStatus, type WatchdogCandidate } from './watchdog.js';
|
|
41
|
+
import { type WatchdogAgentDecider } from './watchdog-agent.js';
|
|
37
42
|
import { type OpenBlock } from '../feed/feed.js';
|
|
38
43
|
import { type RotateGateResult, type RotatePhase, type RotateState } from './rotate.js';
|
|
39
44
|
/** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
|
|
@@ -52,16 +57,7 @@ export interface WatchdogTickOptions {
|
|
|
52
57
|
nudge?: boolean;
|
|
53
58
|
/** Nudge text delivered into the terminal. Default "Continue." */
|
|
54
59
|
nudgeText?: string;
|
|
55
|
-
/**
|
|
56
|
-
* Force the LLM decider (`agents run`) on EVERY stalled candidate instead of the
|
|
57
|
-
* hybrid path. Default false. Even when false the tick still ESCALATES the
|
|
58
|
-
* judgment-heavy cases (parked-on-question, ambiguous stalls) to the smart brain
|
|
59
|
-
* — `smart: true` additionally routes the obvious promise-without-toolcall
|
|
60
|
-
* nudges through it. Non-reproducible; the deterministic pre-filter is the
|
|
61
|
-
* reproducible default.
|
|
62
|
-
*/
|
|
63
|
-
smart?: boolean;
|
|
64
|
-
/** Agent the smart decider runs as (and the built-in fallback prompt). Default 'claude'. */
|
|
60
|
+
/** Agent the watchdog decider runs as (and the built-in prompt). Default 'claude'. */
|
|
65
61
|
smartAgent?: string;
|
|
66
62
|
/** Threshold overrides. Missing fields fall back to DEFAULT_THRESHOLDS. */
|
|
67
63
|
thresholds?: Partial<WatchdogThresholds>;
|
|
@@ -82,13 +78,20 @@ export interface WatchdogTickOptions {
|
|
|
82
78
|
/** Per-session policy. Default = the on-disk sentinel. */
|
|
83
79
|
policyFor?: (s: ActiveSession) => WatchdogPolicy;
|
|
84
80
|
/**
|
|
85
|
-
* The
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
81
|
+
* The decider seam: given an idle candidate, decide nudge vs skip and craft the
|
|
82
|
+
* message. Production leaves this unset — the tick judges every idle session in
|
|
83
|
+
* ONE batched `agents run … --mode plan` call (watchdog-agent.ts). Tests inject a
|
|
84
|
+
* synthetic per-candidate decider so the decision path is exercised without
|
|
85
|
+
* shelling out.
|
|
90
86
|
*/
|
|
91
87
|
smartDecider?: SmartDecider;
|
|
88
|
+
/**
|
|
89
|
+
* The batched agent decider used in production (all idle candidates → one call).
|
|
90
|
+
* Default `makeWatchdogAgentDecider`. Tests inject one (e.g. returning an empty
|
|
91
|
+
* map) to exercise the batched path and the no-verdict fallback without shelling
|
|
92
|
+
* out. Ignored when `smartDecider` (the per-candidate test seam) is set.
|
|
93
|
+
*/
|
|
94
|
+
agentDecider?: WatchdogAgentDecider;
|
|
92
95
|
/** Open feed block for a session (parked-on-question detection). Default reads the feed. */
|
|
93
96
|
openBlockFor?: (s: ActiveSession) => OpenBlock | null;
|
|
94
97
|
/** Inject primitive. Default injectIntoTerminal — tests capture the resolved target. */
|
|
@@ -206,31 +209,19 @@ export interface WatchdogTickResult {
|
|
|
206
209
|
export declare function readPolicySentinel(dir: string, sessionId: string): WatchdogPolicy;
|
|
207
210
|
/** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
|
|
208
211
|
export declare function writePolicySentinel(dir: string, sessionId: string, policy: WatchdogPolicy): void;
|
|
209
|
-
/** A
|
|
212
|
+
/** A decision for one idle session. `text` overrides the default nudge text. */
|
|
210
213
|
export interface NudgeDecision {
|
|
211
214
|
nudge: boolean;
|
|
212
215
|
reason: string;
|
|
213
216
|
text?: string;
|
|
214
217
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* "session is done" skip does NOT trigger a reminder.
|
|
218
|
+
* On a skip, `true` = the agent judged the session genuinely needs the human
|
|
219
|
+
* (surface it — self-file reminder / owner page); `false` / absent = idle-and-
|
|
220
|
+
* done, leave it alone. Gates the self-file reminder so a finished session is
|
|
221
|
+
* never poked.
|
|
220
222
|
*/
|
|
221
223
|
needsHuman?: boolean;
|
|
222
224
|
}
|
|
223
|
-
/**
|
|
224
|
-
* The smart brain. Resolves a `watchdog` workflow for the session's cwd
|
|
225
|
-
* (project > user > system precedence, via resolveWorkflowRef) so a repo/user
|
|
226
|
-
* override AND `model:` frontmatter come for free; when a workflow resolves it
|
|
227
|
-
* runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
|
|
228
|
-
* improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
|
|
229
|
-
* mode keeps the decider read-only. Best-effort and NON-deterministic: any
|
|
230
|
-
* failure (decider unavailable, no verdict) returns a SAFE skip — a parked
|
|
231
|
-
* question we cannot judge is left for the human, never blindly nudged.
|
|
232
|
-
*/
|
|
233
|
-
export declare function makeDefaultSmartDecider(agent: string): SmartDecider;
|
|
234
225
|
/**
|
|
235
226
|
* Run ONE watchdog pass. Returns a structured outcome per live session; injects
|
|
236
227
|
* only when `opts.nudge` is set AND the safety gate says addressable AND policy
|
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
* working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
|
|
4
4
|
* the whole loop runs WITHOUT the Swift menu-bar.
|
|
5
5
|
*
|
|
6
|
-
* One tick
|
|
6
|
+
* One tick:
|
|
7
7
|
*
|
|
8
8
|
* getActiveSessions() (session/active.ts)
|
|
9
|
-
* -> classifyTerminal(...)
|
|
10
|
-
* -> readWatchdogTail(...)
|
|
11
|
-
* ->
|
|
9
|
+
* -> classifyTerminal(...) per session (watchdog/watchdog.ts) — which are idle?
|
|
10
|
+
* -> readWatchdogTail(...) for the idle ones (watchdog/read.ts) — task + transcript tail
|
|
11
|
+
* -> the watchdog AGENT judges them ALL at once (watchdog/watchdog-agent.ts)
|
|
12
|
+
* — idle-but-unfinished -> nudge; idle-and-done / needs-human -> skip
|
|
12
13
|
* -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
|
|
13
|
-
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver
|
|
14
|
+
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver into the EXACT split, then CONFIRM
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* There is no heuristic decider — no regex over the tail guessing done-vs-stuck.
|
|
17
|
+
* The agent is the whole decider, given every idle session's task + tail in ONE
|
|
18
|
+
* `agents run --mode plan` call per tick. The safety gate is absolute: a nudge is
|
|
19
|
+
* delivered ONLY when the resolver returns `addressable: true`, and it is booked in
|
|
20
|
+
* the cooldown ledger ONLY when delivery is CONFIRMED. On `addressable: false` the
|
|
21
|
+
* reason is recorded to a state file the menu-bar can surface and the session is
|
|
22
|
+
* SKIPPED — never a guessed / frontmost target.
|
|
19
23
|
*
|
|
20
24
|
* Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
|
|
21
25
|
* - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
|
|
@@ -25,10 +29,10 @@
|
|
|
25
29
|
* - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
|
|
26
30
|
* - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
|
|
27
31
|
*
|
|
28
|
-
* The pure logic (classifyTerminal
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* The pure logic (classifyTerminal) is imported and never re-implemented; the
|
|
33
|
+
* runner only supplies its I/O (sessions, tails, clock, policy, the agent decider,
|
|
34
|
+
* injection) — each an injectable seam so runner.test.ts drives real synthetic
|
|
35
|
+
* sessions without a live terminal or a real `agents run`.
|
|
32
36
|
*/
|
|
33
37
|
import * as fs from 'fs';
|
|
34
38
|
import * as path from 'path';
|
|
@@ -36,7 +40,8 @@ import * as crypto from 'crypto';
|
|
|
36
40
|
import { getActiveSessions } from '../session/active.js';
|
|
37
41
|
import { reconcilePresence, loadPresence, savePresence, observedFromActive, } from '../session/presence.js';
|
|
38
42
|
import { resolveInjectTargetForSession, injectIntoTerminal, } from '../terminal/index.js';
|
|
39
|
-
import { classifyTerminal,
|
|
43
|
+
import { classifyTerminal, } from './watchdog.js';
|
|
44
|
+
import { makeWatchdogAgentDecider } from './watchdog-agent.js';
|
|
40
45
|
import { readWatchdogTail, WATCHDOG_STALL_MS, WATCHDOG_COOLDOWN_MS, WATCHDOG_DORMANT_MS, WATCHDOG_TAIL_LINES, } from './read.js';
|
|
41
46
|
import { getRuntimeStateDir } from '../state.js';
|
|
42
47
|
import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
|
|
@@ -113,80 +118,6 @@ function defaultLastActivity(s) {
|
|
|
113
118
|
}
|
|
114
119
|
return s.startedAtMs;
|
|
115
120
|
}
|
|
116
|
-
/**
|
|
117
|
-
* COMPLETION_HINTS mirror of watchdog.ts. The deterministic pre-filter screens
|
|
118
|
-
* completions to `skip` before the promise check so a finished-but-idle session
|
|
119
|
-
* is never nudged. (isLikelyTrulyBlocked also guards completion now that its 15m
|
|
120
|
-
* precedence is fixed; this explicit check keeps the pre-filter self-contained.)
|
|
121
|
-
*/
|
|
122
|
-
const COMPLETION_HINTS = ['done', 'completed', 'all set', 'finished'];
|
|
123
|
-
function tailShowsCompletion(candidate) {
|
|
124
|
-
if (candidate.tailLines.length === 0)
|
|
125
|
-
return false;
|
|
126
|
-
const lowerTail = candidate.tailLines.join('\n').toLowerCase();
|
|
127
|
-
return COMPLETION_HINTS.some((hint) => lowerTail.includes(hint));
|
|
128
|
-
}
|
|
129
|
-
function deterministicDecision(session, candidate) {
|
|
130
|
-
// Parked on a question — the case Muqsit cares about most. Do NOT drop it:
|
|
131
|
-
// escalate to the brain, which decides drive-forward vs leave-for-human.
|
|
132
|
-
if (session.activity === 'waiting_input') {
|
|
133
|
-
return {
|
|
134
|
-
kind: 'escalate',
|
|
135
|
-
reason: `parked on a question${session.awaitingReason ? ` (${session.awaitingReason})` : ''} — escalate to the brain`,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
// Clearly complete — cheap skip, no LLM.
|
|
139
|
-
if (tailShowsCompletion(candidate)) {
|
|
140
|
-
return { kind: 'skip', reason: 'tail shows completion (done / finished / all set) — skip' };
|
|
141
|
-
}
|
|
142
|
-
// Clear promise-without-toolcall — cheap nudge, no LLM.
|
|
143
|
-
if (isLikelyTrulyBlocked(candidate)) {
|
|
144
|
-
return { kind: 'nudge', reason: 'stalled after announcing an action with no follow-through' };
|
|
145
|
-
}
|
|
146
|
-
// Ambiguous stall — let the brain judge rather than blindly skip.
|
|
147
|
-
return { kind: 'escalate', reason: 'ambiguous stall — escalate to the brain' };
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* The smart brain. Resolves a `watchdog` workflow for the session's cwd
|
|
151
|
-
* (project > user > system precedence, via resolveWorkflowRef) so a repo/user
|
|
152
|
-
* override AND `model:` frontmatter come for free; when a workflow resolves it
|
|
153
|
-
* runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
|
|
154
|
-
* improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
|
|
155
|
-
* mode keeps the decider read-only. Best-effort and NON-deterministic: any
|
|
156
|
-
* failure (decider unavailable, no verdict) returns a SAFE skip — a parked
|
|
157
|
-
* question we cannot judge is left for the human, never blindly nudged.
|
|
158
|
-
*/
|
|
159
|
-
export function makeDefaultSmartDecider(agent) {
|
|
160
|
-
return async (session, candidate) => {
|
|
161
|
-
const prompt = renderWatchdogPrompt([candidate]);
|
|
162
|
-
try {
|
|
163
|
-
const [{ resolveWorkflowRef }, { execFile }, { promisify }] = await Promise.all([
|
|
164
|
-
import('../workflows.js'),
|
|
165
|
-
import('child_process'),
|
|
166
|
-
import('util'),
|
|
167
|
-
]);
|
|
168
|
-
const cwd = session.cwd || process.cwd();
|
|
169
|
-
const workflowPath = resolveWorkflowRef('watchdog', cwd);
|
|
170
|
-
// A resolved `watchdog` workflow runs by name so its WORKFLOW.md body + model
|
|
171
|
-
// frontmatter apply; otherwise the bare agent runs the built-in prompt.
|
|
172
|
-
const runTarget = workflowPath ? 'watchdog' : agent;
|
|
173
|
-
const execFileAsync = promisify(execFile);
|
|
174
|
-
const { stdout } = await execFileAsync('agents', ['run', runTarget, '--mode', 'plan', prompt], {
|
|
175
|
-
encoding: 'utf8',
|
|
176
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
177
|
-
timeout: 120_000,
|
|
178
|
-
});
|
|
179
|
-
const decisions = parseWatchdogResponse(stdout);
|
|
180
|
-
const d = decisions.find((x) => x.terminalId === candidate.terminalId) ?? decisions[0];
|
|
181
|
-
if (!d)
|
|
182
|
-
return { nudge: false, reason: 'smart decider returned no verdict' };
|
|
183
|
-
return { nudge: d.action === 'nudge', reason: d.reason || `smart: ${d.action}`, text: d.text || undefined };
|
|
184
|
-
}
|
|
185
|
-
catch (err) {
|
|
186
|
-
return { nudge: false, reason: `smart decider unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
121
|
/**
|
|
191
122
|
* Pick the delivery mechanism. resolveAnswerRoute (answer-router.ts) chooses
|
|
192
123
|
* mailbox vs resume vs refuse; resolveInjectTargetForSession (resolve.ts) supplies
|
|
@@ -349,7 +280,6 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
349
280
|
const lastActivityFor = opts.lastActivityFor ?? defaultLastActivity;
|
|
350
281
|
const tailFor = opts.tailFor ?? ((s) => (s.sessionId ? readWatchdogTail(s.sessionId, s.kind, WATCHDOG_TAIL_LINES) : []));
|
|
351
282
|
const policyFor = opts.policyFor ?? ((s) => (s.sessionId ? readPolicySentinel(dir, s.sessionId) : 'keep'));
|
|
352
|
-
const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
|
|
353
283
|
const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
|
|
354
284
|
const injectFn = opts.injectFn ?? injectIntoTerminal;
|
|
355
285
|
const publishBlockFn = opts.publishBlockFn ?? publishBlock;
|
|
@@ -401,6 +331,103 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
401
331
|
injectDryRun: opts.injectDryRun,
|
|
402
332
|
logEvents, flags,
|
|
403
333
|
};
|
|
334
|
+
// --- decide, once, with the AGENT ------------------------------------------
|
|
335
|
+
// Classify every session up front, collect the idle ones (with their task +
|
|
336
|
+
// tail), and hand the WHOLE idle set to the watchdog agent in ONE call
|
|
337
|
+
// (WD-GAP-1: the decider sees the fleet at once, not a lone tail). Tails are
|
|
338
|
+
// cached so the main loop below does not re-read them. Tests inject a
|
|
339
|
+
// per-candidate `smartDecider`; production runs one batched
|
|
340
|
+
// `agents run --mode plan` (watchdog-agent.ts). A session the agent returns no
|
|
341
|
+
// verdict for is a SAFE skip, never a blind nudge.
|
|
342
|
+
const tailCache = new Map();
|
|
343
|
+
const idleCandidates = [];
|
|
344
|
+
for (const session of sessions) {
|
|
345
|
+
const sid = session.sessionId;
|
|
346
|
+
if (!sid)
|
|
347
|
+
continue;
|
|
348
|
+
if (policyFor(session) === 'off')
|
|
349
|
+
continue;
|
|
350
|
+
const la = lastActivityFor(session);
|
|
351
|
+
if (la === undefined)
|
|
352
|
+
continue;
|
|
353
|
+
const st = classifyTerminal({
|
|
354
|
+
lastActivityMs: la, nowMs, lastNudgeMs: ledger[sid] ?? null, optedOut: false,
|
|
355
|
+
stallMs: thresholds.stallMs, cooldownMs: thresholds.cooldownMs, dormantMs: thresholds.dormantMs,
|
|
356
|
+
});
|
|
357
|
+
if (st.kind !== 'stalled')
|
|
358
|
+
continue;
|
|
359
|
+
const tail = tailFor(session);
|
|
360
|
+
tailCache.set(sid, tail);
|
|
361
|
+
// Rotate takes precedence over the nudge agent: a session with an in-flight
|
|
362
|
+
// rotate (or in the failed-rotate cooldown) or a HARD account-limit tail is
|
|
363
|
+
// owned by the rotate machine in the loop below — a capped account cannot be
|
|
364
|
+
// "Continue."d, so the agent must not judge it (and is never even consulted).
|
|
365
|
+
if (rotateEnabled) {
|
|
366
|
+
const inflight = readRotateState(dir, sid);
|
|
367
|
+
if (inflight && (isInflightPhase(inflight.phase) || (inflight.phase === 'failed' && (inflight.suppressUntilMs ?? 0) > nowMs)))
|
|
368
|
+
continue;
|
|
369
|
+
if (classifyTailForRotate(tail, nowMs).kind === 'rate_limited')
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
idleCandidates.push({
|
|
373
|
+
session,
|
|
374
|
+
candidate: {
|
|
375
|
+
terminalId: sid,
|
|
376
|
+
agentType: session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude',
|
|
377
|
+
tailLines: tail,
|
|
378
|
+
stalledForMs: st.stalledForMs,
|
|
379
|
+
task: session.topic ?? session.label ?? session.name,
|
|
380
|
+
cwd: session.cwd,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const decisionByTerminal = new Map();
|
|
385
|
+
if (idleCandidates.length > 0) {
|
|
386
|
+
if (opts.smartDecider) {
|
|
387
|
+
// Test seam: a synthetic per-candidate decider, called once per candidate. A
|
|
388
|
+
// skip with no explicit needsHuman defaults to surfacing it (same rule as the
|
|
389
|
+
// agent path) so an unfinished session is never silently abandoned.
|
|
390
|
+
for (const { session, candidate } of idleCandidates) {
|
|
391
|
+
const raw = await opts.smartDecider(session, candidate);
|
|
392
|
+
decisionByTerminal.set(candidate.terminalId, raw.nudge ? raw : { ...raw, needsHuman: raw.needsHuman ?? true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
// Production: ONE batched agent call for every idle session this tick.
|
|
397
|
+
const decide = opts.agentDecider ?? makeWatchdogAgentDecider(opts.smartAgent ?? 'claude');
|
|
398
|
+
const verdicts = await decide(idleCandidates.map((e) => e.candidate));
|
|
399
|
+
// A decider outage (agent unavailable / timeout) returns no verdicts while
|
|
400
|
+
// idle sessions exist — surface it as an error so it is not an invisible
|
|
401
|
+
// no-op tick (the watchdog silently steering nothing is the failure mode
|
|
402
|
+
// this whole subsystem exists to prevent).
|
|
403
|
+
if (verdicts.size === 0) {
|
|
404
|
+
logEvents.push({
|
|
405
|
+
ts: nowMs, kind: 'error',
|
|
406
|
+
message: `watchdog agent returned no verdicts for ${idleCandidates.length} idle session(s) — decider unavailable this tick`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
for (const { candidate } of idleCandidates) {
|
|
410
|
+
const d = verdicts.get(candidate.terminalId);
|
|
411
|
+
decisionByTerminal.set(candidate.terminalId, d
|
|
412
|
+
? {
|
|
413
|
+
nudge: d.action === 'nudge',
|
|
414
|
+
reason: d.reason || `agent: ${d.action}`,
|
|
415
|
+
text: d.text || undefined,
|
|
416
|
+
// A skip with no explicit needsHuman defaults to surfacing it —
|
|
417
|
+
// never silently abandon an unfinished session (the highest-risk state).
|
|
418
|
+
needsHuman: d.action === 'skip' ? d.needsHuman ?? true : undefined,
|
|
419
|
+
}
|
|
420
|
+
// No verdict for this session — the agent couldn't decide (partial or a
|
|
421
|
+
// decider outage). A NEUTRAL safe-skip: NOT "done" (needsHuman stays
|
|
422
|
+
// undefined, so it is never logged as finished and the reminder never
|
|
423
|
+
// fires), nothing booked, so the next tick re-evaluates it once the
|
|
424
|
+
// decider is back. Marking it done (needsHuman:false) would let an
|
|
425
|
+
// outage abandon every idle session; marking it needsHuman would spam a
|
|
426
|
+
// "you're stuck" reminder into every idle terminal on any outage.
|
|
427
|
+
: { nudge: false, reason: 'watchdog agent returned no verdict — retry next tick' });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
404
431
|
for (const session of sessions) {
|
|
405
432
|
const policy = policyFor(session);
|
|
406
433
|
const base = {
|
|
@@ -464,8 +491,8 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
464
491
|
continue;
|
|
465
492
|
}
|
|
466
493
|
base.stalledForMs = status.stalledForMs;
|
|
467
|
-
// Stalled —
|
|
468
|
-
const tailLines = tailFor(session);
|
|
494
|
+
// Stalled — reuse the tail the pre-pass already read for the agent.
|
|
495
|
+
const tailLines = tailCache.get(session.sessionId) ?? tailFor(session);
|
|
469
496
|
const candidate = {
|
|
470
497
|
terminalId: session.sessionId,
|
|
471
498
|
agentType: (session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude'),
|
|
@@ -653,27 +680,10 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
653
680
|
continue;
|
|
654
681
|
}
|
|
655
682
|
}
|
|
656
|
-
// The
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (opts.smart) {
|
|
661
|
-
const d = await smartDecider(session, candidate);
|
|
662
|
-
// Mark needsHuman when the brain explicitly concluded "leave for human".
|
|
663
|
-
decision = d.nudge ? d : { ...d, needsHuman: true };
|
|
664
|
-
}
|
|
665
|
-
else {
|
|
666
|
-
const det = deterministicDecision(session, candidate);
|
|
667
|
-
if (det.kind === 'escalate') {
|
|
668
|
-
const d = await smartDecider(session, candidate);
|
|
669
|
-
// Mark needsHuman when the brain escalation path concluded "leave for human".
|
|
670
|
-
decision = d.nudge ? d : { ...d, needsHuman: true };
|
|
671
|
-
}
|
|
672
|
-
else {
|
|
673
|
-
// Cheap deterministic path — completion or clear stall. Never "needs human".
|
|
674
|
-
decision = { nudge: det.kind === 'nudge', reason: det.reason };
|
|
675
|
-
}
|
|
676
|
-
}
|
|
683
|
+
// The decision was made up front by the watchdog agent over the whole idle
|
|
684
|
+
// set (see the pre-pass). A session with no verdict is a safe skip.
|
|
685
|
+
const decision = decisionByTerminal.get(session.sessionId) ??
|
|
686
|
+
{ nudge: false, reason: 'not evaluated by the watchdog agent', needsHuman: false };
|
|
677
687
|
const chosenText = decision.text ?? nudgeText;
|
|
678
688
|
// Log the decision for the Factory watchdog card.
|
|
679
689
|
const summary = summarizeWatchdogTail(tailLines, candidate.agentType);
|
|
@@ -796,34 +806,37 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
796
806
|
}
|
|
797
807
|
// Deliver. injectDryRun exercises the path without a real side effect: inject
|
|
798
808
|
// still calls injectFn (which honors dryRun), mailbox/resume are short-circuited.
|
|
809
|
+
// `confirmed` distinguishes a delivery we KNOW reached the agent (tmux/iterm/pty,
|
|
810
|
+
// mailbox, resume) from one merely dispatched (vscodium's fire-and-forget
|
|
811
|
+
// --open-url). Only a CONFIRMED delivery is booked as a landed nudge.
|
|
799
812
|
let delivered;
|
|
800
813
|
if (plan.via === 'inject') {
|
|
801
814
|
try {
|
|
802
815
|
const r = await injectFn(plan.target, chosenText, { dryRun: opts.injectDryRun });
|
|
803
|
-
delivered = { ok: r.ok, error: r.error };
|
|
816
|
+
delivered = { ok: r.ok, confirmed: r.confirmed, error: r.error };
|
|
804
817
|
}
|
|
805
818
|
catch (err) {
|
|
806
|
-
delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
819
|
+
delivered = { ok: false, confirmed: false, error: err instanceof Error ? err.message : String(err) };
|
|
807
820
|
}
|
|
808
821
|
}
|
|
809
822
|
else if (plan.via === 'mailbox') {
|
|
810
823
|
if (opts.injectDryRun) {
|
|
811
|
-
delivered = { ok: true };
|
|
824
|
+
delivered = { ok: true, confirmed: true };
|
|
812
825
|
}
|
|
813
826
|
else {
|
|
814
827
|
try {
|
|
815
828
|
deliverViaMailbox(plan.mailboxId, chosenText, block);
|
|
816
|
-
delivered = { ok: true };
|
|
829
|
+
delivered = { ok: true, confirmed: true };
|
|
817
830
|
}
|
|
818
831
|
catch (err) {
|
|
819
|
-
delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
832
|
+
delivered = { ok: false, confirmed: false, error: err instanceof Error ? err.message : String(err) };
|
|
820
833
|
}
|
|
821
834
|
}
|
|
822
835
|
}
|
|
823
836
|
else {
|
|
824
|
-
delivered = opts.injectDryRun ? { ok: true } : await deliverViaResume(session, chosenText);
|
|
837
|
+
delivered = opts.injectDryRun ? { ok: true, confirmed: true } : { ...(await deliverViaResume(session, chosenText)), confirmed: true };
|
|
825
838
|
}
|
|
826
|
-
if (delivered.ok) {
|
|
839
|
+
if (delivered.ok && delivered.confirmed) {
|
|
827
840
|
ledgerUpdates[session.sessionId] = nowMs; // start the cooldown clock
|
|
828
841
|
logEvents.push({
|
|
829
842
|
ts: nowMs, kind: 'nudge', terminalId: session.sessionId, agentType: candidate.agentType,
|
|
@@ -835,6 +848,24 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
835
848
|
nudgeText: chosenText,
|
|
836
849
|
});
|
|
837
850
|
}
|
|
851
|
+
else if (delivered.ok && !delivered.confirmed) {
|
|
852
|
+
// Dispatched but UNCONFIRMED (vscodium --open-url handed off, but the ext may
|
|
853
|
+
// have no-op'd the verb). Do NOT claim it landed — record `undelivered` in
|
|
854
|
+
// history so the phantom-nudge is visible. Still start the cooldown so a
|
|
855
|
+
// possibly-working ext session is not re-nudged every tick; the honest
|
|
856
|
+
// `undelivered` signal tells the operator to ship the swarm-ext ack.
|
|
857
|
+
ledgerUpdates[session.sessionId] = nowMs;
|
|
858
|
+
logEvents.push({
|
|
859
|
+
ts: nowMs, kind: 'undelivered', terminalId: session.sessionId, agentType: candidate.agentType,
|
|
860
|
+
message: `dispatched via ${viaLabel(plan)} but UNCONFIRMED (needs swarm-ext ack)`,
|
|
861
|
+
reason: decision.reason, nudgeText: chosenText,
|
|
862
|
+
});
|
|
863
|
+
outcomes.push({
|
|
864
|
+
...base, decision: 'skip', addressable, rail, via: plan.via, injected: false,
|
|
865
|
+
reason: `nudge dispatched via ${viaLabel(plan)} but delivery is UNCONFIRMED (swarm-ext ack pending)`,
|
|
866
|
+
nudgeText: chosenText,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
838
869
|
else {
|
|
839
870
|
outcomes.push({
|
|
840
871
|
...base, decision: 'skip', addressable, rail, via: plan.via, injected: false,
|