@phnx-labs/agents-cli 1.20.35 → 1.20.36
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 +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +156 -75
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.js +20 -8
- package/dist/commands/sessions.d.ts +71 -1
- package/dist/commands/sessions.js +365 -37
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +112 -8
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +57 -0
- package/dist/lib/daemon.js +192 -16
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +14 -0
- package/dist/lib/hosts/tasks.js +15 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugins.js +16 -6
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +54 -0
- package/dist/lib/session/active.js +190 -19
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +111 -28
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.js +19 -7
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +16 -2
- package/dist/lib/session/sync/config.d.ts +1 -15
- package/dist/lib/session/sync/config.js +4 -20
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog runner — the CONSUMER that wires the merged pure pieces into a
|
|
3
|
+
* working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
|
|
4
|
+
* the whole loop runs WITHOUT the Swift menu-bar.
|
|
5
|
+
*
|
|
6
|
+
* One tick, per session:
|
|
7
|
+
*
|
|
8
|
+
* getActiveSessions() (session/active.ts)
|
|
9
|
+
* -> classifyTerminal(...) (watchdog/watchdog.ts) — stalled?
|
|
10
|
+
* -> readWatchdogTail(...) (watchdog/read.ts) — the transcript tail
|
|
11
|
+
* -> isLikelyTrulyBlocked(...) (watchdog/watchdog.ts) — promise-without-toolcall, NOT waiting-on-user
|
|
12
|
+
* -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
|
|
13
|
+
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver "Continue." into the EXACT split
|
|
14
|
+
*
|
|
15
|
+
* The safety gate is absolute: a nudge is delivered ONLY when the resolver
|
|
16
|
+
* returns `addressable: true`. On `addressable: false` the reason is recorded to
|
|
17
|
+
* a state file the menu-bar can surface later and the session is SKIPPED — never
|
|
18
|
+
* a guessed / frontmost target.
|
|
19
|
+
*
|
|
20
|
+
* Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
|
|
21
|
+
* - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
|
|
22
|
+
* - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
|
|
23
|
+
* - last-tick.json — the full outcome list from the most recent tick.
|
|
24
|
+
* - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
|
|
25
|
+
*
|
|
26
|
+
* The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
|
|
27
|
+
* re-implemented; the runner only supplies its I/O (sessions, tails, clock,
|
|
28
|
+
* policy, injection) — each an injectable seam so runner.test.ts drives real
|
|
29
|
+
* synthetic sessions without a live terminal.
|
|
30
|
+
*/
|
|
31
|
+
import type { ActiveSession } from '../session/active.js';
|
|
32
|
+
import { type InjectRail } from '../terminal/resolve.js';
|
|
33
|
+
import { type StallStatus } from './watchdog.js';
|
|
34
|
+
/** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
|
|
35
|
+
export type WatchdogPolicy = 'off' | 'keep' | 'handsoff';
|
|
36
|
+
/** Stall / cooldown / dormant thresholds (ms). Defaults mirror read.ts. */
|
|
37
|
+
export interface WatchdogThresholds {
|
|
38
|
+
stallMs: number;
|
|
39
|
+
cooldownMs: number;
|
|
40
|
+
dormantMs: number;
|
|
41
|
+
}
|
|
42
|
+
export declare const DEFAULT_THRESHOLDS: WatchdogThresholds;
|
|
43
|
+
/** The default nudge text — a short imperative, configurable via opts.nudgeText. */
|
|
44
|
+
export declare const DEFAULT_NUDGE_TEXT = "Continue.";
|
|
45
|
+
export interface WatchdogTickOptions {
|
|
46
|
+
/** Actually inject when a nudge is decided. Default false (dry status). */
|
|
47
|
+
nudge?: boolean;
|
|
48
|
+
/** Nudge text delivered into the terminal. Default "Continue." */
|
|
49
|
+
nudgeText?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Use the LLM decider (`agents run`) to decide + choose nudge text instead of
|
|
52
|
+
* the deterministic promise-without-toolcall path. Default false so ticks are
|
|
53
|
+
* reproducible. Best-effort: a decider failure falls back to skip.
|
|
54
|
+
*/
|
|
55
|
+
smart?: boolean;
|
|
56
|
+
/** Agent the smart decider runs as. Default 'claude'. */
|
|
57
|
+
smartAgent?: string;
|
|
58
|
+
/** Threshold overrides. Missing fields fall back to DEFAULT_THRESHOLDS. */
|
|
59
|
+
thresholds?: Partial<WatchdogThresholds>;
|
|
60
|
+
/** Permit the coarse, focus-stealing Ghostty path. Off by default. */
|
|
61
|
+
allowGhosttyFocus?: boolean;
|
|
62
|
+
/** Pass dryRun through to injectIntoTerminal (tests set true — no real terminal). */
|
|
63
|
+
injectDryRun?: boolean;
|
|
64
|
+
/** Session list. Default getActiveSessions(). Tests pass synthetic sessions. */
|
|
65
|
+
sessions?: ActiveSession[];
|
|
66
|
+
/** Clock. Default Date.now(). Tests pin it. */
|
|
67
|
+
nowMs?: number;
|
|
68
|
+
/** Override the state directory (tests point at a tmpdir). */
|
|
69
|
+
stateDir?: string;
|
|
70
|
+
/** lastActivity (ms) for a session. Default = its transcript mtime. */
|
|
71
|
+
lastActivityFor?: (s: ActiveSession) => number | undefined;
|
|
72
|
+
/** Transcript tail lines for a session. Default readWatchdogTail(). */
|
|
73
|
+
tailFor?: (s: ActiveSession) => string[];
|
|
74
|
+
/** Per-session policy. Default = the on-disk sentinel. */
|
|
75
|
+
policyFor?: (s: ActiveSession) => WatchdogPolicy;
|
|
76
|
+
}
|
|
77
|
+
/** What the tick decided for a single session — the row `--json` / the tray reads. */
|
|
78
|
+
export interface SessionOutcome {
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
kind: string;
|
|
81
|
+
host?: string;
|
|
82
|
+
cwd?: string;
|
|
83
|
+
label?: string;
|
|
84
|
+
/** classifyTerminal's verdict for this session. */
|
|
85
|
+
stall: StallStatus['kind'];
|
|
86
|
+
/** stalled duration (ms), when stalled. */
|
|
87
|
+
stalledForMs?: number;
|
|
88
|
+
policy: WatchdogPolicy;
|
|
89
|
+
decision: 'nudge' | 'skip';
|
|
90
|
+
reason: string;
|
|
91
|
+
/** The resolved rail, when addressable. */
|
|
92
|
+
rail?: InjectRail;
|
|
93
|
+
/** True when resolveInjectTarget said addressable (only meaningful once we'd nudge). */
|
|
94
|
+
addressable?: boolean;
|
|
95
|
+
/** True when a nudge was actually delivered this tick. */
|
|
96
|
+
injected?: boolean;
|
|
97
|
+
/** The text that was (or would be) delivered. */
|
|
98
|
+
nudgeText?: string;
|
|
99
|
+
}
|
|
100
|
+
export interface WatchdogTickResult {
|
|
101
|
+
atMs: number;
|
|
102
|
+
/** Whether this tick was allowed to inject (opts.nudge). */
|
|
103
|
+
didNudge: boolean;
|
|
104
|
+
outcomes: SessionOutcome[];
|
|
105
|
+
/** Convenience counts for the menu-bar / status line. */
|
|
106
|
+
counts: {
|
|
107
|
+
total: number;
|
|
108
|
+
stalled: number;
|
|
109
|
+
nudged: number;
|
|
110
|
+
unaddressable: number;
|
|
111
|
+
skipped: number;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* On-disk per-session policy sentinel: `<stateDir>/policy/<sessionId>` whose
|
|
116
|
+
* contents are `off` | `keep` | `handsoff`. Absent / unreadable / unknown → keep.
|
|
117
|
+
*/
|
|
118
|
+
export declare function readPolicySentinel(dir: string, sessionId: string): WatchdogPolicy;
|
|
119
|
+
/** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
|
|
120
|
+
export declare function writePolicySentinel(dir: string, sessionId: string, policy: WatchdogPolicy): void;
|
|
121
|
+
/**
|
|
122
|
+
* Run ONE watchdog pass. Returns a structured outcome per live session; injects
|
|
123
|
+
* only when `opts.nudge` is set AND the safety gate says addressable AND policy
|
|
124
|
+
* permits. Persists the cooldown ledger, un-addressable flags, and a last-tick
|
|
125
|
+
* snapshot to the tray-readable state dir.
|
|
126
|
+
*/
|
|
127
|
+
export declare function runWatchdogTick(opts?: WatchdogTickOptions): Promise<WatchdogTickResult>;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog runner — the CONSUMER that wires the merged pure pieces into a
|
|
3
|
+
* working auto-nudge (RUSH-1415). The `agents watchdog` command drives it, so
|
|
4
|
+
* the whole loop runs WITHOUT the Swift menu-bar.
|
|
5
|
+
*
|
|
6
|
+
* One tick, per session:
|
|
7
|
+
*
|
|
8
|
+
* getActiveSessions() (session/active.ts)
|
|
9
|
+
* -> classifyTerminal(...) (watchdog/watchdog.ts) — stalled?
|
|
10
|
+
* -> readWatchdogTail(...) (watchdog/read.ts) — the transcript tail
|
|
11
|
+
* -> isLikelyTrulyBlocked(...) (watchdog/watchdog.ts) — promise-without-toolcall, NOT waiting-on-user
|
|
12
|
+
* -> resolveInjectTargetForSession(...) (terminal/resolve.ts) — THE safety gate: addressable or an honest refusal
|
|
13
|
+
* -> injectIntoTerminal(target,text) (terminal/inject.ts) — deliver "Continue." into the EXACT split
|
|
14
|
+
*
|
|
15
|
+
* The safety gate is absolute: a nudge is delivered ONLY when the resolver
|
|
16
|
+
* returns `addressable: true`. On `addressable: false` the reason is recorded to
|
|
17
|
+
* a state file the menu-bar can surface later and the session is SKIPPED — never
|
|
18
|
+
* a guessed / frontmost target.
|
|
19
|
+
*
|
|
20
|
+
* Persistence (all under ~/.agents/.cache/state/watchdog/, tray-readable):
|
|
21
|
+
* - nudges.json — { [sessionId]: lastNudgeMs } — enforces the cooldown.
|
|
22
|
+
* - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
|
|
23
|
+
* - last-tick.json — the full outcome list from the most recent tick.
|
|
24
|
+
* - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
|
|
25
|
+
*
|
|
26
|
+
* The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
|
|
27
|
+
* re-implemented; the runner only supplies its I/O (sessions, tails, clock,
|
|
28
|
+
* policy, injection) — each an injectable seam so runner.test.ts drives real
|
|
29
|
+
* synthetic sessions without a live terminal.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from 'fs';
|
|
32
|
+
import * as path from 'path';
|
|
33
|
+
import { getActiveSessions } from '../session/active.js';
|
|
34
|
+
import { resolveInjectTargetForSession, } from '../terminal/resolve.js';
|
|
35
|
+
import { injectIntoTerminal } from '../terminal/inject.js';
|
|
36
|
+
import { classifyTerminal, isLikelyTrulyBlocked, renderWatchdogPrompt, parseWatchdogResponse, } from './watchdog.js';
|
|
37
|
+
import { readWatchdogTail, WATCHDOG_STALL_MS, WATCHDOG_COOLDOWN_MS, WATCHDOG_DORMANT_MS, WATCHDOG_TAIL_LINES, } from './read.js';
|
|
38
|
+
import { getRuntimeStateDir } from '../state.js';
|
|
39
|
+
export const DEFAULT_THRESHOLDS = {
|
|
40
|
+
stallMs: WATCHDOG_STALL_MS,
|
|
41
|
+
cooldownMs: WATCHDOG_COOLDOWN_MS,
|
|
42
|
+
dormantMs: WATCHDOG_DORMANT_MS,
|
|
43
|
+
};
|
|
44
|
+
/** The default nudge text — a short imperative, configurable via opts.nudgeText. */
|
|
45
|
+
export const DEFAULT_NUDGE_TEXT = 'Continue.';
|
|
46
|
+
// --- state persistence ------------------------------------------------------
|
|
47
|
+
function watchdogStateDir(opts) {
|
|
48
|
+
return opts.stateDir ?? path.join(getRuntimeStateDir(), 'watchdog');
|
|
49
|
+
}
|
|
50
|
+
function readJsonFile(file, fallback) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function writeJsonFile(file, value) {
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* best-effort: the tray tolerates a missing/partial state file */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Last-nudge timestamps keyed by sessionId (the cooldown ledger). */
|
|
68
|
+
function readNudgeLedger(dir) {
|
|
69
|
+
return readJsonFile(path.join(dir, 'nudges.json'), {});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* On-disk per-session policy sentinel: `<stateDir>/policy/<sessionId>` whose
|
|
73
|
+
* contents are `off` | `keep` | `handsoff`. Absent / unreadable / unknown → keep.
|
|
74
|
+
*/
|
|
75
|
+
export function readPolicySentinel(dir, sessionId) {
|
|
76
|
+
if (!sessionId)
|
|
77
|
+
return 'keep';
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = fs.readFileSync(path.join(dir, 'policy', sessionId), 'utf8').trim().toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return 'keep';
|
|
84
|
+
}
|
|
85
|
+
return raw === 'off' || raw === 'handsoff' ? raw : 'keep';
|
|
86
|
+
}
|
|
87
|
+
/** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
|
|
88
|
+
export function writePolicySentinel(dir, sessionId, policy) {
|
|
89
|
+
const file = path.join(dir, 'policy', sessionId);
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
fs.writeFileSync(file, policy + '\n');
|
|
92
|
+
}
|
|
93
|
+
// --- helpers ----------------------------------------------------------------
|
|
94
|
+
function defaultLastActivity(s) {
|
|
95
|
+
if (s.sessionFile) {
|
|
96
|
+
try {
|
|
97
|
+
return fs.statSync(s.sessionFile).mtimeMs;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* file vanished */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return s.startedAtMs;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Minimal local mirror of COMPLETION_HINTS in watchdog.ts:42-47. We cannot lean
|
|
107
|
+
* on isLikelyTrulyBlocked to screen completions out: its FORCE_REVIEW_STALL_MS
|
|
108
|
+
* short-circuit (watchdog.ts:171 — `stalledForMs >= 15m` returns true) fires
|
|
109
|
+
* BEFORE its own COMPLETION_HINTS check (watchdog.ts:176), so a session idle
|
|
110
|
+
* 15m-60m whose tail says "done"/"finished" would be reported as blocked. Keep
|
|
111
|
+
* this list in sync with the core.
|
|
112
|
+
*/
|
|
113
|
+
const COMPLETION_HINTS = ['done', 'completed', 'all set', 'finished'];
|
|
114
|
+
function tailShowsCompletion(candidate) {
|
|
115
|
+
if (candidate.tailLines.length === 0)
|
|
116
|
+
return false;
|
|
117
|
+
const lowerTail = candidate.tailLines.join('\n').toLowerCase();
|
|
118
|
+
return COMPLETION_HINTS.some((hint) => lowerTail.includes(hint));
|
|
119
|
+
}
|
|
120
|
+
function deterministicDecision(session, candidate) {
|
|
121
|
+
if (session.activity === 'waiting_input') {
|
|
122
|
+
return { nudge: false, reason: `waiting on user${session.awaitingReason ? ` (${session.awaitingReason})` : ''}` };
|
|
123
|
+
}
|
|
124
|
+
// Completion is checked EXPLICITLY here — never nudge a finished session, even
|
|
125
|
+
// past the 15m force-review window where isLikelyTrulyBlocked would return true
|
|
126
|
+
// before reaching its completion check (see COMPLETION_HINTS note above).
|
|
127
|
+
if (tailShowsCompletion(candidate)) {
|
|
128
|
+
return { nudge: false, reason: 'tail shows completion (done / finished / all set) — skip regardless of stall age' };
|
|
129
|
+
}
|
|
130
|
+
if (isLikelyTrulyBlocked(candidate)) {
|
|
131
|
+
return { nudge: true, reason: 'stalled after announcing an action with no follow-through' };
|
|
132
|
+
}
|
|
133
|
+
return { nudge: false, reason: 'no promise-without-toolcall signal in tail (completion / question / unclear)' };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The optional LLM decider. Shells out to `agents run <agent>` with the watchdog
|
|
137
|
+
* prompt and parses its JSON verdict. Best-effort and NON-deterministic — the
|
|
138
|
+
* default path is deterministicDecision. Isolated here so the tick stays pure
|
|
139
|
+
* over its seams; the command layer opts in with --smart.
|
|
140
|
+
*/
|
|
141
|
+
async function smartDecision(candidate, agent) {
|
|
142
|
+
const prompt = renderWatchdogPrompt([candidate]);
|
|
143
|
+
try {
|
|
144
|
+
const { execFile } = await import('child_process');
|
|
145
|
+
const { promisify } = await import('util');
|
|
146
|
+
const execFileAsync = promisify(execFile);
|
|
147
|
+
const { stdout } = await execFileAsync('agents', ['run', agent, '--mode', 'plan', prompt], {
|
|
148
|
+
encoding: 'utf8',
|
|
149
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
150
|
+
timeout: 120_000,
|
|
151
|
+
});
|
|
152
|
+
const decisions = parseWatchdogResponse(stdout);
|
|
153
|
+
const d = decisions.find((x) => x.terminalId === candidate.terminalId) ?? decisions[0];
|
|
154
|
+
if (!d)
|
|
155
|
+
return { nudge: false, reason: 'smart decider returned no verdict' };
|
|
156
|
+
return { nudge: d.action === 'nudge', reason: d.reason || `smart: ${d.action}`, text: d.text || undefined };
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return { nudge: false, reason: `smart decider unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// --- the tick ---------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* Run ONE watchdog pass. Returns a structured outcome per live session; injects
|
|
165
|
+
* only when `opts.nudge` is set AND the safety gate says addressable AND policy
|
|
166
|
+
* permits. Persists the cooldown ledger, un-addressable flags, and a last-tick
|
|
167
|
+
* snapshot to the tray-readable state dir.
|
|
168
|
+
*/
|
|
169
|
+
export async function runWatchdogTick(opts = {}) {
|
|
170
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
171
|
+
const nudgeText = opts.nudgeText ?? DEFAULT_NUDGE_TEXT;
|
|
172
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...opts.thresholds };
|
|
173
|
+
const dir = watchdogStateDir(opts);
|
|
174
|
+
const lastActivityFor = opts.lastActivityFor ?? defaultLastActivity;
|
|
175
|
+
const tailFor = opts.tailFor ?? ((s) => (s.sessionId ? readWatchdogTail(s.sessionId, s.kind, WATCHDOG_TAIL_LINES) : []));
|
|
176
|
+
const policyFor = opts.policyFor ?? ((s) => (s.sessionId ? readPolicySentinel(dir, s.sessionId) : 'keep'));
|
|
177
|
+
const sessions = opts.sessions ?? (await getActiveSessions());
|
|
178
|
+
const ledger = readNudgeLedger(dir);
|
|
179
|
+
const flags = {};
|
|
180
|
+
const outcomes = [];
|
|
181
|
+
for (const session of sessions) {
|
|
182
|
+
const policy = policyFor(session);
|
|
183
|
+
const base = {
|
|
184
|
+
sessionId: session.sessionId,
|
|
185
|
+
kind: session.kind,
|
|
186
|
+
host: session.host,
|
|
187
|
+
cwd: session.cwd,
|
|
188
|
+
label: session.label,
|
|
189
|
+
policy,
|
|
190
|
+
stall: 'active',
|
|
191
|
+
decision: 'skip',
|
|
192
|
+
reason: '',
|
|
193
|
+
nudgeText,
|
|
194
|
+
};
|
|
195
|
+
// A session with no id can neither be addressed nor cooldown-tracked.
|
|
196
|
+
if (!session.sessionId) {
|
|
197
|
+
outcomes.push({ ...base, reason: 'no session id (cannot address or track)' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// `off` = fully opted out. We short-circuit here rather than relying on
|
|
201
|
+
// classifyTerminal (which is always called with optedOut: false below), so the
|
|
202
|
+
// policy reason is reported explicitly.
|
|
203
|
+
if (policy === 'off') {
|
|
204
|
+
outcomes.push({ ...base, stall: 'opted_out', reason: 'policy: off (opted out)' });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const lastActivityMs = lastActivityFor(session);
|
|
208
|
+
if (lastActivityMs === undefined) {
|
|
209
|
+
outcomes.push({ ...base, reason: 'no activity timestamp (no transcript / start time)' });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const status = classifyTerminal({
|
|
213
|
+
lastActivityMs,
|
|
214
|
+
nowMs,
|
|
215
|
+
lastNudgeMs: ledger[session.sessionId] ?? null,
|
|
216
|
+
optedOut: false,
|
|
217
|
+
stallMs: thresholds.stallMs,
|
|
218
|
+
cooldownMs: thresholds.cooldownMs,
|
|
219
|
+
dormantMs: thresholds.dormantMs,
|
|
220
|
+
});
|
|
221
|
+
base.stall = status.kind;
|
|
222
|
+
if (status.kind !== 'stalled') {
|
|
223
|
+
const reason = status.kind === 'active' ? `active (last activity ${Math.round((nowMs - lastActivityMs) / 1000)}s ago)`
|
|
224
|
+
: status.kind === 'dormant' ? 'dormant (idle past the dormant window)'
|
|
225
|
+
: status.kind === 'rate_limited' ? `cooling down (${Math.round(status.cooldownRemainingMs / 1000)}s left)`
|
|
226
|
+
: 'opted out';
|
|
227
|
+
outcomes.push({ ...base, reason });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
base.stalledForMs = status.stalledForMs;
|
|
231
|
+
// Stalled — read the tail and decide.
|
|
232
|
+
const tailLines = tailFor(session);
|
|
233
|
+
const candidate = {
|
|
234
|
+
terminalId: session.sessionId,
|
|
235
|
+
agentType: (session.kind === 'codex' || session.kind === 'gemini' ? session.kind : 'claude'),
|
|
236
|
+
tailLines,
|
|
237
|
+
stalledForMs: status.stalledForMs,
|
|
238
|
+
};
|
|
239
|
+
const decision = opts.smart
|
|
240
|
+
? await smartDecision(candidate, opts.smartAgent ?? 'claude')
|
|
241
|
+
: deterministicDecision(session, candidate);
|
|
242
|
+
const chosenText = decision.text ?? nudgeText;
|
|
243
|
+
if (!decision.nudge) {
|
|
244
|
+
outcomes.push({ ...base, decision: 'skip', reason: decision.reason });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// A nudge is warranted — run it past the safety gate BEFORE any delivery.
|
|
248
|
+
const resolution = resolveInjectTargetForSession(session, {
|
|
249
|
+
allowGhosttyFocus: opts.allowGhosttyFocus,
|
|
250
|
+
});
|
|
251
|
+
if (!resolution.addressable) {
|
|
252
|
+
// Flag the un-addressable stall for the tray; NEVER guess a target.
|
|
253
|
+
flags[session.sessionId] = { reason: resolution.reason, host: session.host, atMs: nowMs };
|
|
254
|
+
outcomes.push({
|
|
255
|
+
...base, decision: 'skip', addressable: false,
|
|
256
|
+
reason: `nudge-worthy but un-addressable — ${resolution.reason}`,
|
|
257
|
+
nudgeText: chosenText,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// handsoff = detect + flag, but never inject. Record a flag so the tray can
|
|
262
|
+
// surface "would-nudge but hands-off" alongside the un-addressable flags.
|
|
263
|
+
if (policy === 'handsoff') {
|
|
264
|
+
flags[session.sessionId] = {
|
|
265
|
+
reason: `handsoff: would nudge via ${resolution.rail} but policy is hands-off`,
|
|
266
|
+
host: session.host,
|
|
267
|
+
atMs: nowMs,
|
|
268
|
+
};
|
|
269
|
+
outcomes.push({
|
|
270
|
+
...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: false,
|
|
271
|
+
reason: `handsoff: flagged, not injected (would nudge via ${resolution.rail})`,
|
|
272
|
+
nudgeText: chosenText,
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// Dry status (no --nudge): report what WOULD happen, deliver nothing.
|
|
277
|
+
if (!opts.nudge) {
|
|
278
|
+
outcomes.push({
|
|
279
|
+
...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: false,
|
|
280
|
+
reason: `would nudge via ${resolution.rail} (dry — pass --nudge to inject)`,
|
|
281
|
+
nudgeText: chosenText,
|
|
282
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Deliver into the EXACT resolved split.
|
|
286
|
+
let result;
|
|
287
|
+
try {
|
|
288
|
+
result = await injectIntoTerminal(resolution.target, chosenText, { dryRun: opts.injectDryRun });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
result = { ok: false, backend: resolution.target.backend, writes: 0, error: err instanceof Error ? err.message : String(err) };
|
|
292
|
+
}
|
|
293
|
+
if (result.ok) {
|
|
294
|
+
ledger[session.sessionId] = nowMs; // start the cooldown clock
|
|
295
|
+
outcomes.push({
|
|
296
|
+
...base, decision: 'nudge', addressable: true, rail: resolution.rail, injected: true,
|
|
297
|
+
reason: `nudged via ${resolution.rail}`,
|
|
298
|
+
nudgeText: chosenText,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
outcomes.push({
|
|
303
|
+
...base, decision: 'skip', addressable: true, rail: resolution.rail, injected: false,
|
|
304
|
+
reason: `inject failed via ${resolution.rail}: ${result.error ?? 'unknown error'}`,
|
|
305
|
+
nudgeText: chosenText,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Persist the tray-readable state.
|
|
310
|
+
writeJsonFile(path.join(dir, 'nudges.json'), ledger);
|
|
311
|
+
writeJsonFile(path.join(dir, 'flags.json'), flags);
|
|
312
|
+
const counts = {
|
|
313
|
+
total: outcomes.length,
|
|
314
|
+
stalled: outcomes.filter((o) => o.stall === 'stalled').length,
|
|
315
|
+
nudged: outcomes.filter((o) => o.injected).length,
|
|
316
|
+
unaddressable: outcomes.filter((o) => o.addressable === false).length,
|
|
317
|
+
skipped: outcomes.filter((o) => o.decision === 'skip').length,
|
|
318
|
+
};
|
|
319
|
+
const result = { atMs: nowMs, didNudge: opts.nudge === true, outcomes, counts };
|
|
320
|
+
writeJsonFile(path.join(dir, 'last-tick.json'), result);
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface WatchdogCandidate {
|
|
2
|
+
terminalId: string;
|
|
3
|
+
agentType: 'claude' | 'codex' | 'gemini';
|
|
4
|
+
tailLines: string[];
|
|
5
|
+
stalledForMs: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Decision {
|
|
8
|
+
terminalId: string;
|
|
9
|
+
action: 'nudge' | 'skip';
|
|
10
|
+
text: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
export type StallStatus = {
|
|
14
|
+
kind: 'active';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'dormant';
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'opted_out';
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'rate_limited';
|
|
21
|
+
cooldownRemainingMs: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'stalled';
|
|
24
|
+
stalledForMs: number;
|
|
25
|
+
};
|
|
26
|
+
export interface ClassifyInput {
|
|
27
|
+
lastActivityMs: number;
|
|
28
|
+
nowMs: number;
|
|
29
|
+
lastNudgeMs: number | null;
|
|
30
|
+
optedOut: boolean;
|
|
31
|
+
stallMs: number;
|
|
32
|
+
cooldownMs: number;
|
|
33
|
+
dormantMs: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function classifyTerminal(input: ClassifyInput): StallStatus;
|
|
36
|
+
export declare const WATCHDOG_SYSTEM_PROMPT = "You are a watchdog monitoring AI coding agents that run in terminals.\nFor each stalled terminal below, decide: NUDGE (send a short message to unstick it) or SKIP.\n\nNudge when:\n- The last assistant turn announced an action (\"I'll write X\", \"let me run Y\")\n but no matching tool call followed.\n- The agent appears stuck mid-task with no recent progress and the task is incomplete.\n\nSkip when:\n- The agent asked the user a direct question (waiting on human input).\n- The task looks complete.\n- You cannot tell what the agent is doing.\n\nNudge text must be:\n- One sentence, imperative (\"Show me the file.\", \"Run the tests.\").\n- No emojis. No apologies. Under 120 characters.\n\nRespond with ONLY a JSON array (no prose, no code fence):\n[{\"terminalId\":\"<id>\",\"action\":\"nudge\"|\"skip\",\"text\":\"<message or empty>\",\"reason\":\"<brief>\"}]";
|
|
37
|
+
export declare function composePromptWithPlaybook(basePrompt: string, playbook: string): string;
|
|
38
|
+
export declare function renderWatchdogPrompt(candidates: WatchdogCandidate[], playbook?: string): string;
|
|
39
|
+
export declare function parseWatchdogResponse(stdout: string): Decision[];
|
|
40
|
+
export declare function isLikelyTrulyBlocked(candidate: WatchdogCandidate): boolean;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Watchdog: pure logic for detecting stalled agent terminals and rendering
|
|
2
|
+
// prompts to a headless decider instance that decides whether to nudge them.
|
|
3
|
+
// Ported from Swarmify (extension/src/core/watchdog.ts) — behavior verbatim.
|
|
4
|
+
// Terminal/session delivery lives elsewhere; this module reads no files and
|
|
5
|
+
// touches no host APIs so it can be unit-tested in isolation.
|
|
6
|
+
const FORCE_REVIEW_STALL_MS = 15 * 60 * 1000;
|
|
7
|
+
const BLOCKED_HINTS = [
|
|
8
|
+
'blocked',
|
|
9
|
+
'stuck',
|
|
10
|
+
"can't",
|
|
11
|
+
'cannot',
|
|
12
|
+
'unable',
|
|
13
|
+
'failed',
|
|
14
|
+
'error',
|
|
15
|
+
'exception',
|
|
16
|
+
'traceback',
|
|
17
|
+
'timed out',
|
|
18
|
+
'timeout',
|
|
19
|
+
'rate limit',
|
|
20
|
+
'permission denied',
|
|
21
|
+
];
|
|
22
|
+
const WAITING_HINTS = [
|
|
23
|
+
'waiting on user',
|
|
24
|
+
'awaiting user',
|
|
25
|
+
'askuserquestion',
|
|
26
|
+
];
|
|
27
|
+
const COMPLETION_HINTS = [
|
|
28
|
+
'done',
|
|
29
|
+
'completed',
|
|
30
|
+
'all set',
|
|
31
|
+
'finished',
|
|
32
|
+
];
|
|
33
|
+
const TOOL_CALL_HINTS = [
|
|
34
|
+
'"type":"tool_use"',
|
|
35
|
+
'"type":"tool_call"',
|
|
36
|
+
'"type":"function_call"',
|
|
37
|
+
];
|
|
38
|
+
const ASSISTANT_LINE_HINTS = [
|
|
39
|
+
'"type":"assistant"',
|
|
40
|
+
'"role":"assistant"',
|
|
41
|
+
'"payload":{"type":"message"',
|
|
42
|
+
];
|
|
43
|
+
const PROMISE_HINTS = [
|
|
44
|
+
"i'll",
|
|
45
|
+
'i will',
|
|
46
|
+
'let me',
|
|
47
|
+
'going to',
|
|
48
|
+
"next i'll",
|
|
49
|
+
'next i will',
|
|
50
|
+
];
|
|
51
|
+
export function classifyTerminal(input) {
|
|
52
|
+
if (input.optedOut)
|
|
53
|
+
return { kind: 'opted_out' };
|
|
54
|
+
const age = input.nowMs - input.lastActivityMs;
|
|
55
|
+
if (age < input.stallMs)
|
|
56
|
+
return { kind: 'active' };
|
|
57
|
+
if (age > input.dormantMs)
|
|
58
|
+
return { kind: 'dormant' };
|
|
59
|
+
if (input.lastNudgeMs !== null) {
|
|
60
|
+
const sinceNudge = input.nowMs - input.lastNudgeMs;
|
|
61
|
+
if (sinceNudge < input.cooldownMs) {
|
|
62
|
+
return { kind: 'rate_limited', cooldownRemainingMs: input.cooldownMs - sinceNudge };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'stalled', stalledForMs: age };
|
|
66
|
+
}
|
|
67
|
+
export const WATCHDOG_SYSTEM_PROMPT = `You are a watchdog monitoring AI coding agents that run in terminals.
|
|
68
|
+
For each stalled terminal below, decide: NUDGE (send a short message to unstick it) or SKIP.
|
|
69
|
+
|
|
70
|
+
Nudge when:
|
|
71
|
+
- The last assistant turn announced an action ("I'll write X", "let me run Y")
|
|
72
|
+
but no matching tool call followed.
|
|
73
|
+
- The agent appears stuck mid-task with no recent progress and the task is incomplete.
|
|
74
|
+
|
|
75
|
+
Skip when:
|
|
76
|
+
- The agent asked the user a direct question (waiting on human input).
|
|
77
|
+
- The task looks complete.
|
|
78
|
+
- You cannot tell what the agent is doing.
|
|
79
|
+
|
|
80
|
+
Nudge text must be:
|
|
81
|
+
- One sentence, imperative ("Show me the file.", "Run the tests.").
|
|
82
|
+
- No emojis. No apologies. Under 120 characters.
|
|
83
|
+
|
|
84
|
+
Respond with ONLY a JSON array (no prose, no code fence):
|
|
85
|
+
[{"terminalId":"<id>","action":"nudge"|"skip","text":"<message or empty>","reason":"<brief>"}]`;
|
|
86
|
+
// User-editable playbook appended below the built-in prompt. The user maintains
|
|
87
|
+
// the source at ~/.agents/playbooks/watchdog.md (read by the delivery layer);
|
|
88
|
+
// this function is pure so it can be tested without filesystem access.
|
|
89
|
+
export function composePromptWithPlaybook(basePrompt, playbook) {
|
|
90
|
+
const trimmed = playbook.trim();
|
|
91
|
+
if (!trimmed)
|
|
92
|
+
return basePrompt;
|
|
93
|
+
return `${basePrompt}\n\n## House Rules (user playbook)\n\n${trimmed}`;
|
|
94
|
+
}
|
|
95
|
+
export function renderWatchdogPrompt(candidates, playbook = '') {
|
|
96
|
+
const systemPrompt = composePromptWithPlaybook(WATCHDOG_SYSTEM_PROMPT, playbook);
|
|
97
|
+
const parts = [systemPrompt, '', 'STALLED TERMINALS:', ''];
|
|
98
|
+
for (const c of candidates) {
|
|
99
|
+
const seconds = Math.round(c.stalledForMs / 1000);
|
|
100
|
+
parts.push(`--- terminal ${c.terminalId} (${c.agentType}, idle ${seconds}s) ---`);
|
|
101
|
+
parts.push('last JSONL lines:');
|
|
102
|
+
for (const line of c.tailLines) {
|
|
103
|
+
parts.push(line);
|
|
104
|
+
}
|
|
105
|
+
parts.push('');
|
|
106
|
+
}
|
|
107
|
+
return parts.join('\n');
|
|
108
|
+
}
|
|
109
|
+
export function parseWatchdogResponse(stdout) {
|
|
110
|
+
if (!stdout || !stdout.trim())
|
|
111
|
+
return [];
|
|
112
|
+
const arrayMatch = stdout.match(/\[[\s\S]*\]/);
|
|
113
|
+
if (!arrayMatch)
|
|
114
|
+
return [];
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(arrayMatch[0]);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(parsed))
|
|
123
|
+
return [];
|
|
124
|
+
const decisions = [];
|
|
125
|
+
for (const d of parsed) {
|
|
126
|
+
if (!d || typeof d !== 'object')
|
|
127
|
+
continue;
|
|
128
|
+
const obj = d;
|
|
129
|
+
const terminalId = typeof obj.terminalId === 'string' ? obj.terminalId : '';
|
|
130
|
+
const action = obj.action === 'nudge' || obj.action === 'skip' ? obj.action : null;
|
|
131
|
+
const text = typeof obj.text === 'string' ? obj.text : '';
|
|
132
|
+
const reason = typeof obj.reason === 'string' ? obj.reason : '';
|
|
133
|
+
if (!terminalId || !action)
|
|
134
|
+
continue;
|
|
135
|
+
decisions.push({ terminalId, action, text, reason });
|
|
136
|
+
}
|
|
137
|
+
return decisions;
|
|
138
|
+
}
|
|
139
|
+
export function isLikelyTrulyBlocked(candidate) {
|
|
140
|
+
if (candidate.stalledForMs >= FORCE_REVIEW_STALL_MS)
|
|
141
|
+
return true;
|
|
142
|
+
if (candidate.tailLines.length === 0)
|
|
143
|
+
return false;
|
|
144
|
+
const lowerTail = candidate.tailLines.join('\n').toLowerCase();
|
|
145
|
+
if (WAITING_HINTS.some((hint) => lowerTail.includes(hint)))
|
|
146
|
+
return false;
|
|
147
|
+
if (COMPLETION_HINTS.some((hint) => lowerTail.includes(hint)))
|
|
148
|
+
return false;
|
|
149
|
+
if (BLOCKED_HINTS.some((hint) => lowerTail.includes(hint)))
|
|
150
|
+
return true;
|
|
151
|
+
let sawToolAfter = false;
|
|
152
|
+
for (let i = candidate.tailLines.length - 1; i >= 0; i--) {
|
|
153
|
+
const line = candidate.tailLines[i].toLowerCase();
|
|
154
|
+
if (TOOL_CALL_HINTS.some((hint) => line.includes(hint))) {
|
|
155
|
+
sawToolAfter = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!sawToolAfter) {
|
|
159
|
+
const isAssistantLine = ASSISTANT_LINE_HINTS.some((hint) => line.includes(hint));
|
|
160
|
+
const hasPromise = PROMISE_HINTS.some((hint) => line.includes(hint));
|
|
161
|
+
if (isAssistantLine && hasPromise)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|