@phnx-labs/agents-cli 1.20.73 → 1.20.76
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 +989 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +31 -1
- package/dist/commands/exec.js +31 -3
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +87 -9
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/setup.js +6 -1
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +51 -11
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +99 -31
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +56 -0
- package/dist/lib/agents.js +133 -7
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +60 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +25 -0
- package/dist/lib/import.js +107 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +27 -1
- package/dist/lib/shims.js +88 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +15 -36
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +48 -2
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +22 -2
- package/dist/lib/versions.js +98 -10
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watchdog, expressed as a routine.
|
|
3
|
+
*
|
|
4
|
+
* The always-on watchdog is not a bespoke subsystem — it is a plain `command`
|
|
5
|
+
* routine the daemon's existing scheduler fires: `agents watchdog --nudge`, once
|
|
6
|
+
* every couple of minutes. Enabling / disabling the watchdog is enabling /
|
|
7
|
+
* disabling that routine, so there is ONE durable, inspectable concept
|
|
8
|
+
* (`agents routines list` shows it; the scheduler catches it up if the daemon was
|
|
9
|
+
* down) instead of a private sentinel file and a hand-rolled `--watch` loop.
|
|
10
|
+
*
|
|
11
|
+
* A `command` routine — not `agent`/`workflow` — because the tick is deterministic
|
|
12
|
+
* housekeeping: no LLM, no auth, no sandbox. Each fire runs ONE bounded tick (the
|
|
13
|
+
* cron is the loop), so the daemon's per-fire model fits without a long-lived
|
|
14
|
+
* process.
|
|
15
|
+
*/
|
|
16
|
+
import { type JobConfig } from '../routines.js';
|
|
17
|
+
/** The routine name. `agents routines list` / `readJob` key on this. */
|
|
18
|
+
export declare const WATCHDOG_ROUTINE_NAME = "watchdog";
|
|
19
|
+
/**
|
|
20
|
+
* Fire every 2 minutes. Mirrors the extension's 120s tick, and is well inside the
|
|
21
|
+
* 5m stall threshold and 20m nudge cooldown, so a stall is noticed promptly
|
|
22
|
+
* without the daemon spawning a tick more often than the cooldown can matter.
|
|
23
|
+
*/
|
|
24
|
+
export declare const WATCHDOG_ROUTINE_SCHEDULE = "*/2 * * * *";
|
|
25
|
+
/** The plain shell each fire runs: one bounded, deterministic nudge tick. */
|
|
26
|
+
export declare const WATCHDOG_ROUTINE_COMMAND = "agents watchdog --nudge";
|
|
27
|
+
/**
|
|
28
|
+
* Build the watchdog routine config. Pure — touches no disk. `mode`/`effort`/
|
|
29
|
+
* `timeout` are required by JobConfig but ignored for `command` routines;
|
|
30
|
+
* writeJob() drops the default ('auto'/'10m') values so the on-disk file stays
|
|
31
|
+
* to the point (name + schedule + command + enabled).
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildWatchdogRoutine(enabled: boolean): JobConfig;
|
|
34
|
+
/** Is the watchdog routine present on disk (user or system layer)? */
|
|
35
|
+
export declare function watchdogRoutineExists(): boolean;
|
|
36
|
+
/** Is the watchdog routine present AND enabled? */
|
|
37
|
+
export declare function isWatchdogRoutineEnabled(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Ensure the watchdog routine exists with the requested enabled state, preserving
|
|
40
|
+
* any user edits. Absent → create it; present → only flip `enabled` (never clobber
|
|
41
|
+
* a schedule or command the user tuned). Idempotent: calling it twice with the
|
|
42
|
+
* same state is a no-op after the first.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ensureWatchdogRoutine(enabled: boolean): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watchdog, expressed as a routine.
|
|
3
|
+
*
|
|
4
|
+
* The always-on watchdog is not a bespoke subsystem — it is a plain `command`
|
|
5
|
+
* routine the daemon's existing scheduler fires: `agents watchdog --nudge`, once
|
|
6
|
+
* every couple of minutes. Enabling / disabling the watchdog is enabling /
|
|
7
|
+
* disabling that routine, so there is ONE durable, inspectable concept
|
|
8
|
+
* (`agents routines list` shows it; the scheduler catches it up if the daemon was
|
|
9
|
+
* down) instead of a private sentinel file and a hand-rolled `--watch` loop.
|
|
10
|
+
*
|
|
11
|
+
* A `command` routine — not `agent`/`workflow` — because the tick is deterministic
|
|
12
|
+
* housekeeping: no LLM, no auth, no sandbox. Each fire runs ONE bounded tick (the
|
|
13
|
+
* cron is the loop), so the daemon's per-fire model fits without a long-lived
|
|
14
|
+
* process.
|
|
15
|
+
*/
|
|
16
|
+
import { readJob, writeJob, setJobEnabled } from '../routines.js';
|
|
17
|
+
/** The routine name. `agents routines list` / `readJob` key on this. */
|
|
18
|
+
export const WATCHDOG_ROUTINE_NAME = 'watchdog';
|
|
19
|
+
/**
|
|
20
|
+
* Fire every 2 minutes. Mirrors the extension's 120s tick, and is well inside the
|
|
21
|
+
* 5m stall threshold and 20m nudge cooldown, so a stall is noticed promptly
|
|
22
|
+
* without the daemon spawning a tick more often than the cooldown can matter.
|
|
23
|
+
*/
|
|
24
|
+
export const WATCHDOG_ROUTINE_SCHEDULE = '*/2 * * * *';
|
|
25
|
+
/** The plain shell each fire runs: one bounded, deterministic nudge tick. */
|
|
26
|
+
export const WATCHDOG_ROUTINE_COMMAND = 'agents watchdog --nudge';
|
|
27
|
+
/**
|
|
28
|
+
* Build the watchdog routine config. Pure — touches no disk. `mode`/`effort`/
|
|
29
|
+
* `timeout` are required by JobConfig but ignored for `command` routines;
|
|
30
|
+
* writeJob() drops the default ('auto'/'10m') values so the on-disk file stays
|
|
31
|
+
* to the point (name + schedule + command + enabled).
|
|
32
|
+
*/
|
|
33
|
+
export function buildWatchdogRoutine(enabled) {
|
|
34
|
+
return {
|
|
35
|
+
name: WATCHDOG_ROUTINE_NAME,
|
|
36
|
+
schedule: WATCHDOG_ROUTINE_SCHEDULE,
|
|
37
|
+
command: WATCHDOG_ROUTINE_COMMAND,
|
|
38
|
+
enabled,
|
|
39
|
+
mode: 'auto',
|
|
40
|
+
effort: 'auto',
|
|
41
|
+
timeout: '10m',
|
|
42
|
+
prompt: '',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Is the watchdog routine present on disk (user or system layer)? */
|
|
46
|
+
export function watchdogRoutineExists() {
|
|
47
|
+
return readJob(WATCHDOG_ROUTINE_NAME) !== null;
|
|
48
|
+
}
|
|
49
|
+
/** Is the watchdog routine present AND enabled? */
|
|
50
|
+
export function isWatchdogRoutineEnabled() {
|
|
51
|
+
const job = readJob(WATCHDOG_ROUTINE_NAME);
|
|
52
|
+
return job !== null && job.enabled === true;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Ensure the watchdog routine exists with the requested enabled state, preserving
|
|
56
|
+
* any user edits. Absent → create it; present → only flip `enabled` (never clobber
|
|
57
|
+
* a schedule or command the user tuned). Idempotent: calling it twice with the
|
|
58
|
+
* same state is a no-op after the first.
|
|
59
|
+
*/
|
|
60
|
+
export function ensureWatchdogRoutine(enabled) {
|
|
61
|
+
const existing = readJob(WATCHDOG_ROUTINE_NAME);
|
|
62
|
+
if (existing === null) {
|
|
63
|
+
writeJob(buildWatchdogRoutine(enabled));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (existing.enabled !== enabled) {
|
|
67
|
+
setJobEnabled(WATCHDOG_ROUTINE_NAME, enabled);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import type { ActiveSession } from '../session/active.js';
|
|
32
32
|
import { type InjectRail } from '../terminal/resolve.js';
|
|
33
|
-
import { type
|
|
33
|
+
import { type InjectResult, type InjectTarget } from '../terminal/inject.js';
|
|
34
|
+
import { type StallStatus, type WatchdogCandidate } from './watchdog.js';
|
|
35
|
+
import { type OpenBlock } from '../feed.js';
|
|
34
36
|
/** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
|
|
35
37
|
export type WatchdogPolicy = 'off' | 'keep' | 'handsoff';
|
|
36
38
|
/** Stall / cooldown / dormant thresholds (ms). Defaults mirror read.ts. */
|
|
@@ -48,12 +50,15 @@ export interface WatchdogTickOptions {
|
|
|
48
50
|
/** Nudge text delivered into the terminal. Default "Continue." */
|
|
49
51
|
nudgeText?: string;
|
|
50
52
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* Force the LLM decider (`agents run`) on EVERY stalled candidate instead of the
|
|
54
|
+
* hybrid path. Default false. Even when false the tick still ESCALATES the
|
|
55
|
+
* judgment-heavy cases (parked-on-question, ambiguous stalls) to the smart brain
|
|
56
|
+
* — `smart: true` additionally routes the obvious promise-without-toolcall
|
|
57
|
+
* nudges through it. Non-reproducible; the deterministic pre-filter is the
|
|
58
|
+
* reproducible default.
|
|
54
59
|
*/
|
|
55
60
|
smart?: boolean;
|
|
56
|
-
/** Agent the smart decider runs as. Default 'claude'. */
|
|
61
|
+
/** Agent the smart decider runs as (and the built-in fallback prompt). Default 'claude'. */
|
|
57
62
|
smartAgent?: string;
|
|
58
63
|
/** Threshold overrides. Missing fields fall back to DEFAULT_THRESHOLDS. */
|
|
59
64
|
thresholds?: Partial<WatchdogThresholds>;
|
|
@@ -73,7 +78,25 @@ export interface WatchdogTickOptions {
|
|
|
73
78
|
tailFor?: (s: ActiveSession) => string[];
|
|
74
79
|
/** Per-session policy. Default = the on-disk sentinel. */
|
|
75
80
|
policyFor?: (s: ActiveSession) => WatchdogPolicy;
|
|
81
|
+
/**
|
|
82
|
+
* The smart brain: given a stalled candidate, decide drive-forward vs
|
|
83
|
+
* leave-for-human and craft the message. Default resolves a `watchdog` workflow
|
|
84
|
+
* (repo/user override + `model:` frontmatter) and, failing that, the improved
|
|
85
|
+
* built-in prompt — both via `agents run … --mode plan`. Tests inject a
|
|
86
|
+
* synthetic decider so escalation is exercised without shelling out.
|
|
87
|
+
*/
|
|
88
|
+
smartDecider?: SmartDecider;
|
|
89
|
+
/** Open feed block for a session (parked-on-question detection). Default reads the feed. */
|
|
90
|
+
openBlockFor?: (s: ActiveSession) => OpenBlock | null;
|
|
91
|
+
/** Inject primitive. Default injectIntoTerminal — tests capture the resolved target. */
|
|
92
|
+
injectFn?: (target: InjectTarget, text: string, opts: {
|
|
93
|
+
dryRun?: boolean;
|
|
94
|
+
}) => Promise<InjectResult>;
|
|
95
|
+
/** Override the canonical watchdog.log path (tests point at a tmp file). */
|
|
96
|
+
logPath?: string;
|
|
76
97
|
}
|
|
98
|
+
/** The smart brain seam: a stalled candidate in, a nudge decision out. */
|
|
99
|
+
export type SmartDecider = (session: ActiveSession, candidate: WatchdogCandidate) => Promise<NudgeDecision>;
|
|
77
100
|
/** What the tick decided for a single session — the row `--json` / the tray reads. */
|
|
78
101
|
export interface SessionOutcome {
|
|
79
102
|
sessionId?: string;
|
|
@@ -88,15 +111,19 @@ export interface SessionOutcome {
|
|
|
88
111
|
policy: WatchdogPolicy;
|
|
89
112
|
decision: 'nudge' | 'skip';
|
|
90
113
|
reason: string;
|
|
91
|
-
/** The resolved rail, when
|
|
114
|
+
/** The resolved rail, when delivered by injecting into a terminal split. */
|
|
92
115
|
rail?: InjectRail;
|
|
116
|
+
/** How the nudge was (or would be) delivered: inject | mailbox | resume. */
|
|
117
|
+
via?: NudgeVia;
|
|
93
118
|
/** True when resolveInjectTarget said addressable (only meaningful once we'd nudge). */
|
|
94
119
|
addressable?: boolean;
|
|
95
|
-
/** True when a nudge was actually delivered this tick. */
|
|
120
|
+
/** True when a nudge was actually delivered this tick (any mechanism). */
|
|
96
121
|
injected?: boolean;
|
|
97
122
|
/** The text that was (or would be) delivered. */
|
|
98
123
|
nudgeText?: string;
|
|
99
124
|
}
|
|
125
|
+
/** Delivery mechanism the answer-router picked for a nudge. */
|
|
126
|
+
export type NudgeVia = 'inject' | 'mailbox' | 'resume';
|
|
100
127
|
export interface WatchdogTickResult {
|
|
101
128
|
atMs: number;
|
|
102
129
|
/** Whether this tick was allowed to inject (opts.nudge). */
|
|
@@ -118,6 +145,23 @@ export interface WatchdogTickResult {
|
|
|
118
145
|
export declare function readPolicySentinel(dir: string, sessionId: string): WatchdogPolicy;
|
|
119
146
|
/** Write a per-session policy sentinel (used by the CLI `agents watchdog policy`). */
|
|
120
147
|
export declare function writePolicySentinel(dir: string, sessionId: string, policy: WatchdogPolicy): void;
|
|
148
|
+
/** A decide result shared by the deterministic and smart paths. `text` overrides the default nudge text. */
|
|
149
|
+
export interface NudgeDecision {
|
|
150
|
+
nudge: boolean;
|
|
151
|
+
reason: string;
|
|
152
|
+
text?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The smart brain. Resolves a `watchdog` workflow for the session's cwd
|
|
156
|
+
* (project > user > system precedence, via resolveWorkflowRef) so a repo/user
|
|
157
|
+
* override AND `model:` frontmatter come for free; when a workflow resolves it
|
|
158
|
+
* runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
|
|
159
|
+
* improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
|
|
160
|
+
* mode keeps the decider read-only. Best-effort and NON-deterministic: any
|
|
161
|
+
* failure (decider unavailable, no verdict) returns a SAFE skip — a parked
|
|
162
|
+
* question we cannot judge is left for the human, never blindly nudged.
|
|
163
|
+
*/
|
|
164
|
+
export declare function makeDefaultSmartDecider(agent: string): SmartDecider;
|
|
121
165
|
/**
|
|
122
166
|
* Run ONE watchdog pass. Returns a structured outcome per live session; injects
|
|
123
167
|
* only when `opts.nudge` is set AND the safety gate says addressable AND policy
|
|
@@ -36,6 +36,13 @@ import { injectIntoTerminal } from '../terminal/inject.js';
|
|
|
36
36
|
import { classifyTerminal, isLikelyTrulyBlocked, renderWatchdogPrompt, parseWatchdogResponse, } from './watchdog.js';
|
|
37
37
|
import { readWatchdogTail, WATCHDOG_STALL_MS, WATCHDOG_COOLDOWN_MS, WATCHDOG_DORMANT_MS, WATCHDOG_TAIL_LINES, } from './read.js';
|
|
38
38
|
import { getRuntimeStateDir } from '../state.js';
|
|
39
|
+
import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
|
|
40
|
+
import { resolveAnswerRoute, isOpenQuestionBlock } from '../answer-router.js';
|
|
41
|
+
import { enqueue, mailboxDir } from '../mailbox.js';
|
|
42
|
+
import { mailboxIdForActiveSession } from '../mailbox-target.js';
|
|
43
|
+
import { readBlock, blockIdForSession } from '../feed.js';
|
|
44
|
+
import { summarizeWatchdogTail } from './watchdogTail.js';
|
|
45
|
+
import { appendWatchdogEvents } from './log.js';
|
|
39
46
|
export const DEFAULT_THRESHOLDS = {
|
|
40
47
|
stallMs: WATCHDOG_STALL_MS,
|
|
41
48
|
cooldownMs: WATCHDOG_COOLDOWN_MS,
|
|
@@ -103,12 +110,10 @@ function defaultLastActivity(s) {
|
|
|
103
110
|
return s.startedAtMs;
|
|
104
111
|
}
|
|
105
112
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* 15m-60m whose tail says "done"/"finished" would be reported as blocked. Keep
|
|
111
|
-
* this list in sync with the core.
|
|
113
|
+
* COMPLETION_HINTS mirror of watchdog.ts. The deterministic pre-filter screens
|
|
114
|
+
* completions to `skip` before the promise check so a finished-but-idle session
|
|
115
|
+
* is never nudged. (isLikelyTrulyBlocked also guards completion now that its 15m
|
|
116
|
+
* precedence is fixed; this explicit check keeps the pre-filter self-contained.)
|
|
112
117
|
*/
|
|
113
118
|
const COMPLETION_HINTS = ['done', 'completed', 'all set', 'finished'];
|
|
114
119
|
function tailShowsCompletion(candidate) {
|
|
@@ -118,45 +123,130 @@ function tailShowsCompletion(candidate) {
|
|
|
118
123
|
return COMPLETION_HINTS.some((hint) => lowerTail.includes(hint));
|
|
119
124
|
}
|
|
120
125
|
function deterministicDecision(session, candidate) {
|
|
126
|
+
// Parked on a question — the case Muqsit cares about most. Do NOT drop it:
|
|
127
|
+
// escalate to the brain, which decides drive-forward vs leave-for-human.
|
|
121
128
|
if (session.activity === 'waiting_input') {
|
|
122
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
kind: 'escalate',
|
|
131
|
+
reason: `parked on a question${session.awaitingReason ? ` (${session.awaitingReason})` : ''} — escalate to the brain`,
|
|
132
|
+
};
|
|
123
133
|
}
|
|
124
|
-
//
|
|
125
|
-
// past the 15m force-review window where isLikelyTrulyBlocked would return true
|
|
126
|
-
// before reaching its completion check (see COMPLETION_HINTS note above).
|
|
134
|
+
// Clearly complete — cheap skip, no LLM.
|
|
127
135
|
if (tailShowsCompletion(candidate)) {
|
|
128
|
-
return {
|
|
136
|
+
return { kind: 'skip', reason: 'tail shows completion (done / finished / all set) — skip' };
|
|
129
137
|
}
|
|
138
|
+
// Clear promise-without-toolcall — cheap nudge, no LLM.
|
|
130
139
|
if (isLikelyTrulyBlocked(candidate)) {
|
|
131
|
-
return {
|
|
140
|
+
return { kind: 'nudge', reason: 'stalled after announcing an action with no follow-through' };
|
|
132
141
|
}
|
|
133
|
-
|
|
142
|
+
// Ambiguous stall — let the brain judge rather than blindly skip.
|
|
143
|
+
return { kind: 'escalate', reason: 'ambiguous stall — escalate to the brain' };
|
|
134
144
|
}
|
|
135
145
|
/**
|
|
136
|
-
* The
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
146
|
+
* The smart brain. Resolves a `watchdog` workflow for the session's cwd
|
|
147
|
+
* (project > user > system precedence, via resolveWorkflowRef) so a repo/user
|
|
148
|
+
* override AND `model:` frontmatter come for free; when a workflow resolves it
|
|
149
|
+
* runs `agents run watchdog --mode plan <prompt>`, else it falls back to the
|
|
150
|
+
* improved built-in prompt via `agents run <agent> --mode plan <prompt>`. Plan
|
|
151
|
+
* mode keeps the decider read-only. Best-effort and NON-deterministic: any
|
|
152
|
+
* failure (decider unavailable, no verdict) returns a SAFE skip — a parked
|
|
153
|
+
* question we cannot judge is left for the human, never blindly nudged.
|
|
140
154
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
export function makeDefaultSmartDecider(agent) {
|
|
156
|
+
return async (session, candidate) => {
|
|
157
|
+
const prompt = renderWatchdogPrompt([candidate]);
|
|
158
|
+
try {
|
|
159
|
+
const [{ resolveWorkflowRef }, { execFile }, { promisify }] = await Promise.all([
|
|
160
|
+
import('../workflows.js'),
|
|
161
|
+
import('child_process'),
|
|
162
|
+
import('util'),
|
|
163
|
+
]);
|
|
164
|
+
const cwd = session.cwd || process.cwd();
|
|
165
|
+
const workflowPath = resolveWorkflowRef('watchdog', cwd);
|
|
166
|
+
// A resolved `watchdog` workflow runs by name so its WORKFLOW.md body + model
|
|
167
|
+
// frontmatter apply; otherwise the bare agent runs the built-in prompt.
|
|
168
|
+
const runTarget = workflowPath ? 'watchdog' : agent;
|
|
169
|
+
const execFileAsync = promisify(execFile);
|
|
170
|
+
const { stdout } = await execFileAsync('agents', ['run', runTarget, '--mode', 'plan', prompt], {
|
|
171
|
+
encoding: 'utf8',
|
|
172
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
173
|
+
timeout: 120_000,
|
|
174
|
+
});
|
|
175
|
+
const decisions = parseWatchdogResponse(stdout);
|
|
176
|
+
const d = decisions.find((x) => x.terminalId === candidate.terminalId) ?? decisions[0];
|
|
177
|
+
if (!d)
|
|
178
|
+
return { nudge: false, reason: 'smart decider returned no verdict' };
|
|
179
|
+
return { nudge: d.action === 'nudge', reason: d.reason || `smart: ${d.action}`, text: d.text || undefined };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return { nudge: false, reason: `smart decider unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Pick the delivery mechanism. resolveAnswerRoute (answer-router.ts) chooses
|
|
188
|
+
* mailbox vs resume vs refuse; resolveInjectTargetForSession (resolve.ts) supplies
|
|
189
|
+
* the precise inject target — CRUCIALLY it handles the vscodium rail that the
|
|
190
|
+
* answer-router's own resolver cannot, so an IDE-terminal (VS Codium / Cursor /
|
|
191
|
+
* VS Code) session parked on a question is injected into its exact terminal rather
|
|
192
|
+
* than being downgraded to resume/refuse. When a precise rail exists it wins
|
|
193
|
+
* (that includes a stalled non-parked agent, so the v1 terminal-inject path is
|
|
194
|
+
* preserved and no nudge is stranded in a mailbox the agent will never poll).
|
|
195
|
+
*/
|
|
196
|
+
function planDelivery(session, chosenText, block, allowGhosttyFocus) {
|
|
197
|
+
const sessionId = session.sessionId ?? '';
|
|
198
|
+
const mailboxId = mailboxIdForActiveSession(session) ?? sessionId;
|
|
199
|
+
const resolution = resolveInjectTargetForSession(session, { allowGhosttyFocus });
|
|
200
|
+
const route = resolveAnswerRoute({ mailboxId, answer: chosenText, session, block });
|
|
201
|
+
// A precise split (tmux / iTerm / vscodium / pty) is the authoritative target.
|
|
202
|
+
if (resolution.addressable) {
|
|
203
|
+
return { via: 'inject', rail: resolution.rail, target: resolution.target };
|
|
204
|
+
}
|
|
205
|
+
// No precise rail: honor the answer-router's parked-agent decision.
|
|
206
|
+
if (route.kind === 'resume')
|
|
207
|
+
return { via: 'resume' };
|
|
208
|
+
// Mailbox is correct ONLY for a still-looping agent that has an OPEN question
|
|
209
|
+
// block — it is seen at the next tool call. A stalled agent that simply stopped
|
|
210
|
+
// (no open block) would never poll the mailbox, so it is flagged instead of
|
|
211
|
+
// silently dropping a nudge into a spool it will never read.
|
|
212
|
+
if (route.kind === 'mailbox' && isOpenQuestionBlock(block))
|
|
213
|
+
return { via: 'mailbox', mailboxId };
|
|
214
|
+
return { via: 'refuse', reason: route.kind === 'refuse' ? route.reason : resolution.reason };
|
|
215
|
+
}
|
|
216
|
+
/** Default open-block reader — the same lookup `agents message` uses. */
|
|
217
|
+
function defaultOpenBlockFor(session) {
|
|
218
|
+
const id = mailboxIdForActiveSession(session) ?? session.sessionId;
|
|
219
|
+
if (!id)
|
|
220
|
+
return null;
|
|
221
|
+
const direct = readBlock(blockIdForSession(id));
|
|
222
|
+
if (direct && direct.mailboxId === id)
|
|
223
|
+
return direct;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/** Enqueue a nudge into a session's mailbox (running agent, seen at next tool call). */
|
|
227
|
+
function deliverViaMailbox(mailboxId, text, block) {
|
|
228
|
+
enqueue(mailboxDir(mailboxId), { to: mailboxId, text, from: 'watchdog', blockId: block?.blockId });
|
|
229
|
+
}
|
|
230
|
+
/** Re-enter a parked headless agent with the nudge as its next user turn. */
|
|
231
|
+
async function deliverViaResume(session, text) {
|
|
232
|
+
const sid = session.sessionId;
|
|
233
|
+
if (!sid)
|
|
234
|
+
return { ok: false, error: 'no session id to resume' };
|
|
143
235
|
try {
|
|
144
|
-
const {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
236
|
+
const [{ getAgentsInvocation }, { spawn }] = await Promise.all([
|
|
237
|
+
import('../daemon.js'),
|
|
238
|
+
import('child_process'),
|
|
239
|
+
]);
|
|
240
|
+
const inv = getAgentsInvocation(['run', session.kind, '--resume', sid, '--', text]);
|
|
241
|
+
const code = await new Promise((resolve) => {
|
|
242
|
+
const child = spawn(inv.command, inv.args, { stdio: 'ignore', env: process.env, detached: false });
|
|
243
|
+
child.on('exit', (c) => resolve(c ?? 1));
|
|
244
|
+
child.on('error', () => resolve(1));
|
|
151
245
|
});
|
|
152
|
-
|
|
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 };
|
|
246
|
+
return code === 0 ? { ok: true } : { ok: false, error: `resume exited ${code}` };
|
|
157
247
|
}
|
|
158
248
|
catch (err) {
|
|
159
|
-
return {
|
|
249
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
160
250
|
}
|
|
161
251
|
}
|
|
162
252
|
// --- the tick ---------------------------------------------------------------
|
|
@@ -174,10 +264,18 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
174
264
|
const lastActivityFor = opts.lastActivityFor ?? defaultLastActivity;
|
|
175
265
|
const tailFor = opts.tailFor ?? ((s) => (s.sessionId ? readWatchdogTail(s.sessionId, s.kind, WATCHDOG_TAIL_LINES) : []));
|
|
176
266
|
const policyFor = opts.policyFor ?? ((s) => (s.sessionId ? readPolicySentinel(dir, s.sessionId) : 'keep'));
|
|
267
|
+
const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
|
|
268
|
+
const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
|
|
269
|
+
const injectFn = opts.injectFn ?? injectIntoTerminal;
|
|
177
270
|
const sessions = opts.sessions ?? (await getActiveSessions());
|
|
178
271
|
const ledger = readNudgeLedger(dir);
|
|
272
|
+
// Cooldown timestamps this tick decided to (re)start — merged into the ledger
|
|
273
|
+
// under a lock at the end so a concurrent tick's updates are never lost.
|
|
274
|
+
const ledgerUpdates = {};
|
|
179
275
|
const flags = {};
|
|
180
276
|
const outcomes = [];
|
|
277
|
+
const logEvents = [];
|
|
278
|
+
const viaLabel = (plan) => plan.via === 'inject' ? `inject (${plan.rail})` : plan.via;
|
|
181
279
|
for (const session of sessions) {
|
|
182
280
|
const policy = policyFor(session);
|
|
183
281
|
const base = {
|
|
@@ -236,39 +334,66 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
236
334
|
tailLines,
|
|
237
335
|
stalledForMs: status.stalledForMs,
|
|
238
336
|
};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
337
|
+
// The brain. The cheap deterministic pre-filter resolves the obvious cases;
|
|
338
|
+
// parked-on-question and ambiguous stalls ESCALATE to the smart brain. `smart`
|
|
339
|
+
// forces every stalled candidate through the brain.
|
|
340
|
+
let decision;
|
|
341
|
+
if (opts.smart) {
|
|
342
|
+
decision = await smartDecider(session, candidate);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const det = deterministicDecision(session, candidate);
|
|
346
|
+
decision = det.kind === 'escalate'
|
|
347
|
+
? await smartDecider(session, candidate)
|
|
348
|
+
: { nudge: det.kind === 'nudge', reason: det.reason };
|
|
349
|
+
}
|
|
242
350
|
const chosenText = decision.text ?? nudgeText;
|
|
351
|
+
// Log the decision for the Factory watchdog card.
|
|
352
|
+
const summary = summarizeWatchdogTail(tailLines, candidate.agentType);
|
|
353
|
+
logEvents.push({
|
|
354
|
+
ts: nowMs,
|
|
355
|
+
kind: 'decision',
|
|
356
|
+
terminalId: session.sessionId,
|
|
357
|
+
agentType: candidate.agentType,
|
|
358
|
+
message: decision.reason,
|
|
359
|
+
reason: decision.reason,
|
|
360
|
+
stalledForMs: status.stalledForMs,
|
|
361
|
+
tailLines,
|
|
362
|
+
nudgeText: decision.nudge ? chosenText : undefined,
|
|
363
|
+
lastUserMessage: summary.lastUserMessage,
|
|
364
|
+
lastAssistantMessage: summary.lastAssistantMessage,
|
|
365
|
+
});
|
|
243
366
|
if (!decision.nudge) {
|
|
244
367
|
outcomes.push({ ...base, decision: 'skip', reason: decision.reason });
|
|
245
368
|
continue;
|
|
246
369
|
}
|
|
247
|
-
// A nudge is warranted —
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
370
|
+
// A nudge is warranted — plan delivery (answer-router picks mailbox/resume/
|
|
371
|
+
// refuse; resolveInjectTargetForSession supplies the vscodium-aware inject
|
|
372
|
+
// target) BEFORE any side effect.
|
|
373
|
+
const block = openBlockFor(session);
|
|
374
|
+
const plan = planDelivery(session, chosenText, block, opts.allowGhosttyFocus);
|
|
375
|
+
const rail = plan.via === 'inject' ? plan.rail : undefined;
|
|
376
|
+
const addressable = plan.via === 'inject' ? true : undefined;
|
|
377
|
+
if (plan.via === 'refuse') {
|
|
378
|
+
// No addressable rail and not headless-resumable — flag, NEVER guess.
|
|
379
|
+
flags[session.sessionId] = { reason: plan.reason, host: session.host, atMs: nowMs };
|
|
254
380
|
outcomes.push({
|
|
255
381
|
...base, decision: 'skip', addressable: false,
|
|
256
|
-
reason: `nudge-worthy but un-addressable — ${
|
|
382
|
+
reason: `nudge-worthy but un-addressable — ${plan.reason}`,
|
|
257
383
|
nudgeText: chosenText,
|
|
258
384
|
});
|
|
259
385
|
continue;
|
|
260
386
|
}
|
|
261
|
-
// handsoff = detect + flag, but never
|
|
262
|
-
// surface "would-nudge but hands-off" alongside the un-addressable flags.
|
|
387
|
+
// handsoff = detect + flag, but never deliver.
|
|
263
388
|
if (policy === 'handsoff') {
|
|
264
389
|
flags[session.sessionId] = {
|
|
265
|
-
reason: `handsoff: would nudge via ${
|
|
390
|
+
reason: `handsoff: would nudge via ${viaLabel(plan)} but policy is hands-off`,
|
|
266
391
|
host: session.host,
|
|
267
392
|
atMs: nowMs,
|
|
268
393
|
};
|
|
269
394
|
outcomes.push({
|
|
270
|
-
...base, decision: 'nudge', addressable
|
|
271
|
-
reason: `handsoff: flagged, not
|
|
395
|
+
...base, decision: 'nudge', addressable, rail, via: plan.via, injected: false,
|
|
396
|
+
reason: `handsoff: flagged, not delivered (would nudge via ${viaLabel(plan)})`,
|
|
272
397
|
nudgeText: chosenText,
|
|
273
398
|
});
|
|
274
399
|
continue;
|
|
@@ -276,38 +401,80 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
276
401
|
// Dry status (no --nudge): report what WOULD happen, deliver nothing.
|
|
277
402
|
if (!opts.nudge) {
|
|
278
403
|
outcomes.push({
|
|
279
|
-
...base, decision: 'nudge', addressable
|
|
280
|
-
reason: `would nudge via ${
|
|
404
|
+
...base, decision: 'nudge', addressable, rail, via: plan.via, injected: false,
|
|
405
|
+
reason: `would nudge via ${viaLabel(plan)} (dry — pass --nudge)`,
|
|
281
406
|
nudgeText: chosenText,
|
|
282
407
|
});
|
|
283
408
|
continue;
|
|
284
409
|
}
|
|
285
|
-
// Deliver
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
410
|
+
// Deliver. injectDryRun exercises the path without a real side effect: inject
|
|
411
|
+
// still calls injectFn (which honors dryRun), mailbox/resume are short-circuited.
|
|
412
|
+
let delivered;
|
|
413
|
+
if (plan.via === 'inject') {
|
|
414
|
+
try {
|
|
415
|
+
const r = await injectFn(plan.target, chosenText, { dryRun: opts.injectDryRun });
|
|
416
|
+
delivered = { ok: r.ok, error: r.error };
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
420
|
+
}
|
|
289
421
|
}
|
|
290
|
-
|
|
291
|
-
|
|
422
|
+
else if (plan.via === 'mailbox') {
|
|
423
|
+
if (opts.injectDryRun) {
|
|
424
|
+
delivered = { ok: true };
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
try {
|
|
428
|
+
deliverViaMailbox(plan.mailboxId, chosenText, block);
|
|
429
|
+
delivered = { ok: true };
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
delivered = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
delivered = opts.injectDryRun ? { ok: true } : await deliverViaResume(session, chosenText);
|
|
292
438
|
}
|
|
293
|
-
if (
|
|
294
|
-
|
|
439
|
+
if (delivered.ok) {
|
|
440
|
+
ledgerUpdates[session.sessionId] = nowMs; // start the cooldown clock
|
|
441
|
+
logEvents.push({
|
|
442
|
+
ts: nowMs, kind: 'nudge', terminalId: session.sessionId, agentType: candidate.agentType,
|
|
443
|
+
message: `nudged via ${viaLabel(plan)}`, reason: decision.reason, nudgeText: chosenText,
|
|
444
|
+
});
|
|
295
445
|
outcomes.push({
|
|
296
|
-
...base, decision: 'nudge', addressable
|
|
297
|
-
reason: `nudged via ${
|
|
446
|
+
...base, decision: 'nudge', addressable, rail, via: plan.via, injected: true,
|
|
447
|
+
reason: `nudged via ${viaLabel(plan)}`,
|
|
298
448
|
nudgeText: chosenText,
|
|
299
449
|
});
|
|
300
450
|
}
|
|
301
451
|
else {
|
|
302
452
|
outcomes.push({
|
|
303
|
-
...base, decision: 'skip', addressable
|
|
304
|
-
reason: `
|
|
453
|
+
...base, decision: 'skip', addressable, rail, via: plan.via, injected: false,
|
|
454
|
+
reason: `nudge via ${viaLabel(plan)} failed: ${delivered.error ?? 'unknown error'}`,
|
|
305
455
|
nudgeText: chosenText,
|
|
306
456
|
});
|
|
307
457
|
}
|
|
308
458
|
}
|
|
309
|
-
// Persist the
|
|
310
|
-
|
|
459
|
+
// Persist the cooldown ledger under a lock: fresh-read + merge this tick's
|
|
460
|
+
// updates + atomic write, so a concurrent tick's timestamps are never lost
|
|
461
|
+
// (the old unlocked read-at-start / write-at-end was a lost-update race).
|
|
462
|
+
if (Object.keys(ledgerUpdates).length > 0) {
|
|
463
|
+
const nudgesPath = path.join(dir, 'nudges.json');
|
|
464
|
+
const ledgerLock = path.join(dir, '.ledger.lock');
|
|
465
|
+
try {
|
|
466
|
+
ensureLockTarget(ledgerLock);
|
|
467
|
+
withFileLock(ledgerLock, () => {
|
|
468
|
+
const current = readNudgeLedger(dir);
|
|
469
|
+
for (const [sid, ts] of Object.entries(ledgerUpdates))
|
|
470
|
+
current[sid] = ts;
|
|
471
|
+
atomicWriteFileSync(nudgesPath, JSON.stringify(current, null, 2));
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
/* best-effort: a lock failure must not throw out of a tick */
|
|
476
|
+
}
|
|
477
|
+
}
|
|
311
478
|
writeJsonFile(path.join(dir, 'flags.json'), flags);
|
|
312
479
|
const counts = {
|
|
313
480
|
total: outcomes.length,
|
|
@@ -318,5 +485,13 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
318
485
|
};
|
|
319
486
|
const result = { atMs: nowMs, didNudge: opts.nudge === true, outcomes, counts };
|
|
320
487
|
writeJsonFile(path.join(dir, 'last-tick.json'), result);
|
|
488
|
+
// Heartbeat + decision/nudge events to the canonical watchdog.log the Factory
|
|
489
|
+
// Floor reads. Best-effort; never throws into the tick.
|
|
490
|
+
logEvents.push({
|
|
491
|
+
ts: nowMs,
|
|
492
|
+
kind: 'tick',
|
|
493
|
+
message: `${counts.total} live · ${counts.stalled} stalled · ${counts.nudged} nudged · ${counts.unaddressable} un-addressable`,
|
|
494
|
+
});
|
|
495
|
+
appendWatchdogEvents(logEvents, { logPath: opts.logPath });
|
|
321
496
|
return result;
|
|
322
497
|
}
|
|
@@ -33,7 +33,7 @@ export interface ClassifyInput {
|
|
|
33
33
|
dormantMs: number;
|
|
34
34
|
}
|
|
35
35
|
export declare function classifyTerminal(input: ClassifyInput): StallStatus;
|
|
36
|
-
export declare const WATCHDOG_SYSTEM_PROMPT = "You are
|
|
36
|
+
export declare const WATCHDOG_SYSTEM_PROMPT = "You are the watchdog for AI coding agents running in terminals. These agents are\nexpected to DRIVE TO COMPLETION end-to-end. They too often stop to ask a needless\nquestion or pause with the task unfinished. For each stalled terminal below, decide\nNUDGE (send a message that unsticks it and pushes it to finish) or SKIP (it genuinely\nneeds the human).\n\nNUDGE when the agent could have continued on its own:\n- It asked permission for an obvious or already-authorized next step\n (\"should I proceed?\", \"want me to continue?\", \"shall I run the tests?\").\n- It asked a question it could answer itself from the available context or a\n reasonable default.\n- It announced an action (\"I'll run X\", \"let me write Y\") but no tool call followed.\n- It paused with the task incomplete and no real blocker.\nThe nudge text MUST: restate the goal, tell it to use best judgment and finish\nend-to-end WITHOUT asking again, and give ONE concrete hint \u2014 the specific next\nstep, a tool it forgot, or the sensible default to take. Imperative, 1-2 sentences,\nno emojis, under 200 characters.\n\nSKIP when the agent genuinely needs the human:\n- Credentials, auth, login, 2FA, or biometric.\n- An irreversible or outward-facing action that needs sign-off (force-push, delete\n prod data, publish/release, spend money, send an external message).\n- A real product or intent decision with genuine ambiguity (not a trivial default).\n- The task is actually complete.\n- You cannot tell what the agent is doing.\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
37
|
export declare function composePromptWithPlaybook(basePrompt: string, playbook: string): string;
|
|
38
38
|
export declare function renderWatchdogPrompt(candidates: WatchdogCandidate[], playbook?: string): string;
|
|
39
39
|
export declare function parseWatchdogResponse(stdout: string): Decision[];
|