@phnx-labs/agents-cli 1.20.35 → 1.20.37
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-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +78 -1
- package/dist/commands/sessions.js +506 -66
- 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 +196 -18
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -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 +73 -0
- package/dist/lib/session/active.js +199 -22
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +39 -0
- package/dist/lib/session/discover.js +127 -29
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- 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/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +19 -17
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +205 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +20 -4
- package/dist/lib/session/sync/config.d.ts +14 -15
- package/dist/lib/session/sync/config.js +60 -20
- package/dist/lib/session/types.d.ts +23 -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 +21 -13
- 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/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- 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,149 @@
|
|
|
1
|
+
// Watchdog tail reader: locate a session transcript from its id + agent and
|
|
2
|
+
// pull the last N raw JSONL lines. The raw lines feed the pure detectors in
|
|
3
|
+
// watchdog.ts (isLikelyTrulyBlocked / renderWatchdogPrompt) and the pure
|
|
4
|
+
// summarizer in watchdogTail.ts.
|
|
5
|
+
//
|
|
6
|
+
// Adapted from Swarmify's readTailLines (extension/src/vscode/sessions.vscode.ts)
|
|
7
|
+
// to agents-cli's session layout. Path resolution REUSES getAgentSessionDirs()
|
|
8
|
+
// from src/lib/session/discover.ts — the same resolver the rest of the CLI uses
|
|
9
|
+
// to enumerate per-version transcript roots — instead of hardcoding
|
|
10
|
+
// ~/.agents/.history/versions/<agent>/.../projects/<enc>/<sessionId>.jsonl.
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { getAgentSessionDirs } from '../session/discover.js';
|
|
14
|
+
import { walkForFiles } from '../fs-walk.js';
|
|
15
|
+
/** Default watchdog thresholds, mirrored from the Swarmify VS Code runtime. */
|
|
16
|
+
export const WATCHDOG_TAIL_LINES = 20;
|
|
17
|
+
export const WATCHDOG_STALL_MS = 300_000; // 5m — stallSeconds default
|
|
18
|
+
export const WATCHDOG_COOLDOWN_MS = 1_200_000; // 20m — cooldownSeconds default
|
|
19
|
+
export const WATCHDOG_DORMANT_MS = 3_600_000; // 1h — DORMANT_MS
|
|
20
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
21
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
22
|
+
/** Cap on files walked per transcript root — matches session/discover.ts. */
|
|
23
|
+
const WATCHDOG_WALK_CAP = 100_000;
|
|
24
|
+
/**
|
|
25
|
+
* Per-agent transcript layout, mirroring the `getAgentSessionDirs()` call sites
|
|
26
|
+
* in session/discover.ts so the resolver never diverges from the rest of the CLI:
|
|
27
|
+
* Claude keeps per-project folders under `projects/` (discover.ts:486); Codex and
|
|
28
|
+
* Droid date-/work-partition rollout `.jsonl` deep under `sessions/`
|
|
29
|
+
* (discover.ts:665, :1673); Gemini nests chat `.json` under `tmp/<hash>/chats/`
|
|
30
|
+
* (discover.ts:820). Subdir + extension are driven from this table instead of the
|
|
31
|
+
* old hardcoded `codex ? 'sessions' : 'projects'` ternary, which sent every
|
|
32
|
+
* non-Codex agent (Gemini included) to the wrong root.
|
|
33
|
+
*/
|
|
34
|
+
const WATCHDOG_SESSION_LAYOUT = {
|
|
35
|
+
claude: { subdir: 'projects', ext: '.jsonl' },
|
|
36
|
+
codex: { subdir: 'sessions', ext: '.jsonl' },
|
|
37
|
+
droid: { subdir: 'sessions', ext: '.jsonl' },
|
|
38
|
+
gemini: { subdir: 'tmp', ext: '.json' },
|
|
39
|
+
};
|
|
40
|
+
const WATCHDOG_SESSION_LAYOUT_DEFAULT = { subdir: 'projects', ext: '.jsonl' };
|
|
41
|
+
/**
|
|
42
|
+
* Read the last `maxLines` non-empty lines of a JSONL transcript by seeking
|
|
43
|
+
* backward from EOF in 64KB chunks. A tail that begins mid-line yields one
|
|
44
|
+
* malformed leading line, which the callers' per-line JSON try/catch tolerates.
|
|
45
|
+
* Returns `[]` on any read error or empty file.
|
|
46
|
+
*/
|
|
47
|
+
export function readTailLines(filePath, maxLines) {
|
|
48
|
+
let fd;
|
|
49
|
+
try {
|
|
50
|
+
fd = fs.openSync(filePath, 'r');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const fileSize = fs.fstatSync(fd).size;
|
|
57
|
+
if (fileSize === 0)
|
|
58
|
+
return [];
|
|
59
|
+
let position = fileSize;
|
|
60
|
+
let buffer = '';
|
|
61
|
+
let collected = [];
|
|
62
|
+
while (position > 0 && collected.length <= maxLines) {
|
|
63
|
+
const readSize = Math.min(CHUNK_SIZE, position);
|
|
64
|
+
position -= readSize;
|
|
65
|
+
const chunk = Buffer.alloc(readSize);
|
|
66
|
+
fs.readSync(fd, chunk, 0, readSize, position);
|
|
67
|
+
buffer = chunk.toString('utf-8') + buffer;
|
|
68
|
+
collected = buffer.split(/\r?\n/).filter((l) => l.trim());
|
|
69
|
+
}
|
|
70
|
+
return collected.slice(-maxLines);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
try {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* fd already gone */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Given candidate transcript-root directories, find the transcript file for a
|
|
86
|
+
* session. Handles the flat Claude layout (`<sessionId>.jsonl` inside a
|
|
87
|
+
* per-project `<enc>/` subfolder), Codex-style names that merely embed the uuid
|
|
88
|
+
* (`rollout-…-<sessionId>.jsonl`), and — critically — the DEEP date partitions
|
|
89
|
+
* Codex/Droid use (`sessions/YYYY/MM/DD/rollout-…-<uuid>.jsonl`). The scan is
|
|
90
|
+
* fully recursive via `walkForFiles` (the same walker session/discover.ts uses),
|
|
91
|
+
* so no fixed number of subdir levels is assumed — a hand-rolled one-level scan
|
|
92
|
+
* silently missed every Codex transcript. Newest mtime wins when a uuid appears
|
|
93
|
+
* in more than one root (e.g. across version homes). Pure over its `dirs`
|
|
94
|
+
* argument, so it is testable without touching the real home directory.
|
|
95
|
+
*/
|
|
96
|
+
export function findSessionJsonlIn(dirs, sessionId, ext = '.jsonl') {
|
|
97
|
+
if (!sessionId)
|
|
98
|
+
return undefined;
|
|
99
|
+
const matches = (name) => {
|
|
100
|
+
if (!name.endsWith(ext))
|
|
101
|
+
return false;
|
|
102
|
+
const stem = name.slice(0, -ext.length);
|
|
103
|
+
if (stem === sessionId)
|
|
104
|
+
return true;
|
|
105
|
+
// Codex embeds the uuid in a longer filename (rollout-<ts>-<uuid>.jsonl).
|
|
106
|
+
return name.includes(sessionId) || (UUID_RE.test(sessionId) && stem.includes(sessionId));
|
|
107
|
+
};
|
|
108
|
+
let best;
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
for (const file of walkForFiles(dir, ext, WATCHDOG_WALK_CAP)) {
|
|
111
|
+
if (!matches(path.basename(file)))
|
|
112
|
+
continue;
|
|
113
|
+
let mtime;
|
|
114
|
+
try {
|
|
115
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!best || mtime > best.mtime)
|
|
121
|
+
best = { file, mtime };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best?.file;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a session transcript path from its id + agent, reusing the CLI's
|
|
128
|
+
* per-version transcript roots. Returns `undefined` if no transcript is found.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveWatchdogSessionPath(sessionId, agent) {
|
|
131
|
+
// Subdir + transcript extension are driven per-agent from WATCHDOG_SESSION_LAYOUT
|
|
132
|
+
// (which mirrors the getAgentSessionDirs() convention in session/discover.ts),
|
|
133
|
+
// not a hardcoded `codex ? 'sessions' : 'projects'` — that ternary sent Codex to
|
|
134
|
+
// a root it only scanned one level deep, and every other agent (Gemini included)
|
|
135
|
+
// to the wrong subdir entirely.
|
|
136
|
+
const layout = WATCHDOG_SESSION_LAYOUT[agent] ?? WATCHDOG_SESSION_LAYOUT_DEFAULT;
|
|
137
|
+
const dirs = getAgentSessionDirs(agent, layout.subdir);
|
|
138
|
+
return findSessionJsonlIn(dirs, sessionId, layout.ext);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read the last `maxLines` JSONL lines for a session by id + agent. Returns
|
|
142
|
+
* `[]` when the transcript cannot be located or read.
|
|
143
|
+
*/
|
|
144
|
+
export function readWatchdogTail(sessionId, agent, maxLines = WATCHDOG_TAIL_LINES) {
|
|
145
|
+
const filePath = resolveWatchdogSessionPath(sessionId, agent);
|
|
146
|
+
if (!filePath)
|
|
147
|
+
return [];
|
|
148
|
+
return readTailLines(filePath, maxLines);
|
|
149
|
+
}
|
|
@@ -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;
|