@preziosiraffaele/agent-watch 0.2.0
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/LICENSE +21 -0
- package/bin/agent-watchd +2 -0
- package/bin/aw +5 -0
- package/dist/agent-watchd.js +603 -0
- package/dist/aw.js +780 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raffaele Preziosi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/agent-watchd
ADDED
package/bin/aw
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/agent-core/src/types.ts
|
|
3
|
+
var AGENT_FILTER_KEYS = ["nvim_server", "repo"];
|
|
4
|
+
// packages/agent-core/src/protocol.ts
|
|
5
|
+
var DEFAULT_DAEMON_HOST = "127.0.0.1";
|
|
6
|
+
var DEFAULT_DAEMON_PORT = 3847;
|
|
7
|
+
var DEFAULT_DAEMON_URL = `http://${DEFAULT_DAEMON_HOST}:${DEFAULT_DAEMON_PORT}`;
|
|
8
|
+
var ENV = {
|
|
9
|
+
LAUNCH_ID: "AGENT_WATCH_LAUNCH_ID",
|
|
10
|
+
DAEMON_URL: "AGENT_WATCH_DAEMON_URL",
|
|
11
|
+
DAEMON_PORT: "AGENT_WATCH_DAEMON_PORT",
|
|
12
|
+
DAEMON_HOST: "AGENT_WATCH_DAEMON_HOST"
|
|
13
|
+
};
|
|
14
|
+
var SUPPORTED_AGENTS = ["claude", "codex", "agent"];
|
|
15
|
+
function isSupportedAgent(name) {
|
|
16
|
+
return SUPPORTED_AGENTS.includes(name);
|
|
17
|
+
}
|
|
18
|
+
// packages/agent-core/src/states.ts
|
|
19
|
+
var STATES = {
|
|
20
|
+
SESSION_STARTED: "session_started",
|
|
21
|
+
WORKING: "working",
|
|
22
|
+
RUNNING_TOOL: "running_tool",
|
|
23
|
+
RUNNING_SHELL: "running_shell",
|
|
24
|
+
NEEDS_APPROVAL: "needs_approval",
|
|
25
|
+
EDITED_FILE: "edited_file",
|
|
26
|
+
WAITING: "waiting",
|
|
27
|
+
IDLE: "idle",
|
|
28
|
+
STALE: "stale",
|
|
29
|
+
EXITED: "exited",
|
|
30
|
+
FAILED: "failed"
|
|
31
|
+
};
|
|
32
|
+
var STALE_AFTER_MS = 30 * 60 * 1000;
|
|
33
|
+
// packages/agent-core/src/events.ts
|
|
34
|
+
var CLAUDE_STATE = {
|
|
35
|
+
SessionStart: STATES.SESSION_STARTED,
|
|
36
|
+
UserPromptSubmit: STATES.WORKING,
|
|
37
|
+
UserPromptExpansion: STATES.WORKING,
|
|
38
|
+
PreToolUse: STATES.RUNNING_TOOL,
|
|
39
|
+
PermissionRequest: STATES.NEEDS_APPROVAL,
|
|
40
|
+
PermissionDenied: STATES.WORKING,
|
|
41
|
+
PostToolUse: STATES.WORKING,
|
|
42
|
+
PostToolUseFailure: STATES.WORKING,
|
|
43
|
+
PostToolBatch: STATES.WORKING,
|
|
44
|
+
Notification: STATES.WAITING,
|
|
45
|
+
SubagentStart: STATES.RUNNING_TOOL,
|
|
46
|
+
SubagentStop: STATES.IDLE,
|
|
47
|
+
TaskCreated: STATES.RUNNING_TOOL,
|
|
48
|
+
TaskCompleted: STATES.WORKING,
|
|
49
|
+
Stop: STATES.IDLE,
|
|
50
|
+
StopFailure: STATES.IDLE,
|
|
51
|
+
SessionEnd: STATES.IDLE
|
|
52
|
+
};
|
|
53
|
+
var CODEX_STATE = {
|
|
54
|
+
SessionStart: STATES.SESSION_STARTED,
|
|
55
|
+
UserPromptSubmit: STATES.WORKING,
|
|
56
|
+
PreToolUse: STATES.RUNNING_TOOL,
|
|
57
|
+
PermissionRequest: STATES.NEEDS_APPROVAL,
|
|
58
|
+
PostToolUse: STATES.WORKING,
|
|
59
|
+
Stop: STATES.IDLE
|
|
60
|
+
};
|
|
61
|
+
var AGENT_STATE = {
|
|
62
|
+
sessionStart: STATES.SESSION_STARTED,
|
|
63
|
+
beforeSubmitPrompt: STATES.WORKING,
|
|
64
|
+
preToolUse: STATES.RUNNING_TOOL,
|
|
65
|
+
beforeShellExecution: STATES.RUNNING_SHELL,
|
|
66
|
+
afterShellExecution: STATES.WORKING,
|
|
67
|
+
beforeMCPExecution: STATES.RUNNING_TOOL,
|
|
68
|
+
afterMCPExecution: STATES.WORKING,
|
|
69
|
+
beforeReadFile: STATES.RUNNING_TOOL,
|
|
70
|
+
afterFileEdit: STATES.EDITED_FILE,
|
|
71
|
+
postToolUse: STATES.WORKING,
|
|
72
|
+
postToolUseFailure: STATES.WORKING,
|
|
73
|
+
subagentStart: STATES.RUNNING_TOOL,
|
|
74
|
+
subagentStop: STATES.WORKING,
|
|
75
|
+
stop: STATES.IDLE,
|
|
76
|
+
sessionEnd: STATES.IDLE
|
|
77
|
+
};
|
|
78
|
+
var CLAUDE_EVENTS = Object.keys(CLAUDE_STATE);
|
|
79
|
+
var CODEX_EVENTS = Object.keys(CODEX_STATE);
|
|
80
|
+
var AGENT_EVENTS = Object.keys(AGENT_STATE);
|
|
81
|
+
function stateFor(agent, event) {
|
|
82
|
+
if (agent === "claude")
|
|
83
|
+
return CLAUDE_STATE[event] ?? STATES.WORKING;
|
|
84
|
+
if (agent === "codex")
|
|
85
|
+
return CODEX_STATE[event] ?? STATES.WORKING;
|
|
86
|
+
if (agent === "agent")
|
|
87
|
+
return AGENT_STATE[event] ?? STATES.WORKING;
|
|
88
|
+
return STATES.WORKING;
|
|
89
|
+
}
|
|
90
|
+
// packages/agent-core/src/repo.ts
|
|
91
|
+
import { basename } from "path";
|
|
92
|
+
function inferRepoContext(folder, runGit) {
|
|
93
|
+
if (!folder)
|
|
94
|
+
return { repo: null, repo_root: null, branch: null };
|
|
95
|
+
const root = runGit(folder, ["rev-parse", "--show-toplevel"]);
|
|
96
|
+
if (!root)
|
|
97
|
+
return { repo: null, repo_root: null, branch: null };
|
|
98
|
+
const branch = runGit(folder, ["branch", "--show-current"]) || runGit(folder, ["rev-parse", "--short", "HEAD"]);
|
|
99
|
+
return {
|
|
100
|
+
repo: basename(root),
|
|
101
|
+
repo_root: root,
|
|
102
|
+
branch: branch || null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
var realGitRunner = (folder, args) => {
|
|
106
|
+
try {
|
|
107
|
+
const proc = Bun.spawnSync(["git", "-C", folder, ...args], {
|
|
108
|
+
stdout: "pipe",
|
|
109
|
+
stderr: "ignore"
|
|
110
|
+
});
|
|
111
|
+
if (!proc.success)
|
|
112
|
+
return null;
|
|
113
|
+
const output = proc.stdout?.toString().trim();
|
|
114
|
+
return output || null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
// packages/agent-core/src/payload.ts
|
|
120
|
+
function asString(value) {
|
|
121
|
+
if (typeof value === "string" && value.trim())
|
|
122
|
+
return value;
|
|
123
|
+
if (typeof value === "number")
|
|
124
|
+
return String(value);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
function isObject(value) {
|
|
128
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
129
|
+
}
|
|
130
|
+
function firstString(payload, keys) {
|
|
131
|
+
if (!isObject(payload))
|
|
132
|
+
return null;
|
|
133
|
+
for (const key of keys) {
|
|
134
|
+
const found = asString(payload[key]);
|
|
135
|
+
if (found)
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
function firstStringPath(payload, paths) {
|
|
141
|
+
for (const parts of paths) {
|
|
142
|
+
let current = payload;
|
|
143
|
+
let ok = true;
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
if (!isObject(current)) {
|
|
146
|
+
ok = false;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
current = current[part];
|
|
150
|
+
}
|
|
151
|
+
if (ok) {
|
|
152
|
+
const found = asString(current);
|
|
153
|
+
if (found)
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function pickToolName(payload) {
|
|
160
|
+
const command = firstStringPath(payload, [
|
|
161
|
+
["command"],
|
|
162
|
+
["shell_command"],
|
|
163
|
+
["shellCommand"],
|
|
164
|
+
["tool_input", "command"],
|
|
165
|
+
["toolInput", "command"],
|
|
166
|
+
["input", "command"],
|
|
167
|
+
["arguments", "command"],
|
|
168
|
+
["args", "command"]
|
|
169
|
+
]);
|
|
170
|
+
if (command)
|
|
171
|
+
return command;
|
|
172
|
+
const tool = firstString(payload, ["tool_name", "toolName", "tool", "name"]);
|
|
173
|
+
if (tool)
|
|
174
|
+
return tool;
|
|
175
|
+
return firstStringPath(payload, [
|
|
176
|
+
["tool", "name"],
|
|
177
|
+
["tool", "type"],
|
|
178
|
+
["tool_call", "name"],
|
|
179
|
+
["toolCall", "name"]
|
|
180
|
+
]) ?? "-";
|
|
181
|
+
}
|
|
182
|
+
function pickSessionId(payload) {
|
|
183
|
+
return firstString(payload, [
|
|
184
|
+
"session_id",
|
|
185
|
+
"sessionId",
|
|
186
|
+
"session",
|
|
187
|
+
"conversation_id",
|
|
188
|
+
"conversationId",
|
|
189
|
+
"thread_id",
|
|
190
|
+
"threadId"
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
function pickClaudeEventName(payload) {
|
|
194
|
+
return firstString(payload, ["hook_event_name", "hookEventName"]);
|
|
195
|
+
}
|
|
196
|
+
// packages/agent-core/src/daemon-state.ts
|
|
197
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
198
|
+
import { homedir } from "os";
|
|
199
|
+
import { dirname, join } from "path";
|
|
200
|
+
var DAEMON_RUNTIME_DIR_NAME = ".agent-watch";
|
|
201
|
+
var DAEMON_STATE_FILE_NAME = "daemon.json";
|
|
202
|
+
function daemonRuntimeDir(home = homedir()) {
|
|
203
|
+
return join(home, DAEMON_RUNTIME_DIR_NAME);
|
|
204
|
+
}
|
|
205
|
+
function daemonStatePath(runtimeDir = daemonRuntimeDir()) {
|
|
206
|
+
return join(runtimeDir, DAEMON_STATE_FILE_NAME);
|
|
207
|
+
}
|
|
208
|
+
function writeDaemonState(state, path = daemonStatePath()) {
|
|
209
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
210
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
211
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}
|
|
212
|
+
`);
|
|
213
|
+
renameSync(tempPath, path);
|
|
214
|
+
}
|
|
215
|
+
function removeDaemonState(path = daemonStatePath()) {
|
|
216
|
+
try {
|
|
217
|
+
rmSync(path, { force: true });
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
// packages/agent-watchd/src/registry.ts
|
|
221
|
+
function createRegistry(options = {}) {
|
|
222
|
+
const records = new Map;
|
|
223
|
+
const subscribers = new Set;
|
|
224
|
+
const gitRunner = options.gitRunner ?? realGitRunner;
|
|
225
|
+
const now = options.now ?? (() => new Date);
|
|
226
|
+
let nextId = 1;
|
|
227
|
+
let seq = 0;
|
|
228
|
+
const broadcast = (event) => {
|
|
229
|
+
for (const fn of subscribers) {
|
|
230
|
+
try {
|
|
231
|
+
fn(event);
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const register = (reg) => {
|
|
236
|
+
const id = nextId++;
|
|
237
|
+
const repo2 = inferRepoContext(reg.folder, gitRunner);
|
|
238
|
+
const record = {
|
|
239
|
+
id,
|
|
240
|
+
agent: reg.agent,
|
|
241
|
+
state: STATES.SESSION_STARTED,
|
|
242
|
+
title: reg.title ?? null,
|
|
243
|
+
session_id: null,
|
|
244
|
+
recent_events: [],
|
|
245
|
+
tool: "-",
|
|
246
|
+
folder: reg.folder,
|
|
247
|
+
repo: repo2.repo,
|
|
248
|
+
repo_root: repo2.repo_root,
|
|
249
|
+
branch: repo2.branch,
|
|
250
|
+
updated: now().toISOString(),
|
|
251
|
+
agent_process_pid: null,
|
|
252
|
+
nvim_server: reg.nvim?.server ?? null,
|
|
253
|
+
nvim_terminal_bufnr: reg.nvim?.terminalBufnr ?? null
|
|
254
|
+
};
|
|
255
|
+
records.set(id, record);
|
|
256
|
+
broadcast({ type: "created", record });
|
|
257
|
+
return record;
|
|
258
|
+
};
|
|
259
|
+
const update = (id, patch) => {
|
|
260
|
+
const current = records.get(id);
|
|
261
|
+
if (!current)
|
|
262
|
+
return null;
|
|
263
|
+
const next = {
|
|
264
|
+
...current,
|
|
265
|
+
agent_process_pid: patch.agent_process_pid !== undefined ? patch.agent_process_pid : current.agent_process_pid,
|
|
266
|
+
state: patch.state ?? current.state,
|
|
267
|
+
title: patch.title !== undefined ? patch.title : current.title,
|
|
268
|
+
updated: now().toISOString()
|
|
269
|
+
};
|
|
270
|
+
records.set(id, next);
|
|
271
|
+
broadcast({ type: "updated", record: next });
|
|
272
|
+
return next;
|
|
273
|
+
};
|
|
274
|
+
const applyHook = (id, agent, hook) => {
|
|
275
|
+
const current = records.get(id);
|
|
276
|
+
if (!current)
|
|
277
|
+
return null;
|
|
278
|
+
const received_at = now().toISOString();
|
|
279
|
+
seq += 1;
|
|
280
|
+
const state = agent === "claude" && hook.event === "SubagentStop" && current.state === STATES.IDLE ? current.state : hook.state;
|
|
281
|
+
const next = {
|
|
282
|
+
...current,
|
|
283
|
+
agent: current.agent || agent,
|
|
284
|
+
state,
|
|
285
|
+
tool: hook.tool || current.tool,
|
|
286
|
+
session_id: hook.session_id ?? current.session_id,
|
|
287
|
+
recent_events: [...current.recent_events, { event: hook.event, received_at, seq }].slice(-3),
|
|
288
|
+
updated: received_at
|
|
289
|
+
};
|
|
290
|
+
records.set(id, next);
|
|
291
|
+
broadcast({ type: "updated", record: next });
|
|
292
|
+
return next;
|
|
293
|
+
};
|
|
294
|
+
const remove = (id) => {
|
|
295
|
+
if (!records.has(id))
|
|
296
|
+
return false;
|
|
297
|
+
records.delete(id);
|
|
298
|
+
broadcast({ type: "removed", id });
|
|
299
|
+
return true;
|
|
300
|
+
};
|
|
301
|
+
const refreshRepoContext = () => {
|
|
302
|
+
const recordsByFolder = new Map;
|
|
303
|
+
for (const record of records.values()) {
|
|
304
|
+
if (!record.folder)
|
|
305
|
+
continue;
|
|
306
|
+
const grouped = recordsByFolder.get(record.folder);
|
|
307
|
+
if (grouped) {
|
|
308
|
+
grouped.push(record);
|
|
309
|
+
} else {
|
|
310
|
+
recordsByFolder.set(record.folder, [record]);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const changed = [];
|
|
314
|
+
for (const [folder, folderRecords] of recordsByFolder) {
|
|
315
|
+
const repo2 = inferRepoContext(folder, gitRunner);
|
|
316
|
+
for (const current of folderRecords) {
|
|
317
|
+
if (current.repo === repo2.repo && current.repo_root === repo2.repo_root && current.branch === repo2.branch) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const next = {
|
|
321
|
+
...current,
|
|
322
|
+
repo: repo2.repo,
|
|
323
|
+
repo_root: repo2.repo_root,
|
|
324
|
+
branch: repo2.branch,
|
|
325
|
+
updated: now().toISOString()
|
|
326
|
+
};
|
|
327
|
+
records.set(next.id, next);
|
|
328
|
+
changed.push(next);
|
|
329
|
+
broadcast({ type: "updated", record: next });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return changed;
|
|
333
|
+
};
|
|
334
|
+
return {
|
|
335
|
+
register,
|
|
336
|
+
update,
|
|
337
|
+
remove,
|
|
338
|
+
applyHook,
|
|
339
|
+
refreshRepoContext,
|
|
340
|
+
get(id) {
|
|
341
|
+
return records.get(id);
|
|
342
|
+
},
|
|
343
|
+
list() {
|
|
344
|
+
return Array.from(records.values()).sort((a, b) => a.id - b.id);
|
|
345
|
+
},
|
|
346
|
+
subscribe(fn) {
|
|
347
|
+
subscribers.add(fn);
|
|
348
|
+
return () => {
|
|
349
|
+
subscribers.delete(fn);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// packages/agent-watchd/src/queue.ts
|
|
356
|
+
function createPerLaunchQueue() {
|
|
357
|
+
const tails = new Map;
|
|
358
|
+
return {
|
|
359
|
+
enqueue(launchId, task) {
|
|
360
|
+
const previous = tails.get(launchId) ?? Promise.resolve();
|
|
361
|
+
const next = previous.then(() => task());
|
|
362
|
+
const swallowed = next.catch(() => {
|
|
363
|
+
return;
|
|
364
|
+
});
|
|
365
|
+
tails.set(launchId, swallowed);
|
|
366
|
+
swallowed.finally(() => {
|
|
367
|
+
if (tails.get(launchId) === swallowed)
|
|
368
|
+
tails.delete(launchId);
|
|
369
|
+
});
|
|
370
|
+
return next;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// packages/agent-watchd/src/hooks.ts
|
|
376
|
+
function normalizeHook(agent, fallbackEvent, payload2) {
|
|
377
|
+
const event = agent === "claude" && pickClaudeEventName(payload2) || fallbackEvent || "unknown";
|
|
378
|
+
return {
|
|
379
|
+
event,
|
|
380
|
+
state: stateFor(agent, event),
|
|
381
|
+
tool: pickToolName(payload2),
|
|
382
|
+
session_id: pickSessionId(payload2)
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function parseJson(input) {
|
|
386
|
+
if (!input.trim())
|
|
387
|
+
return {};
|
|
388
|
+
try {
|
|
389
|
+
return JSON.parse(input);
|
|
390
|
+
} catch {
|
|
391
|
+
return {};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// packages/agent-watchd/src/server.ts
|
|
396
|
+
var DEFAULT_REPO_CONTEXT_REFRESH_INTERVAL_MS = 1e4;
|
|
397
|
+
var json = (data, init) => new Response(JSON.stringify(data), {
|
|
398
|
+
...init,
|
|
399
|
+
headers: { "content-type": "application/json", ...init?.headers ?? {} }
|
|
400
|
+
});
|
|
401
|
+
var text = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain" } });
|
|
402
|
+
function startDaemon(options = {}) {
|
|
403
|
+
const registry = options.registry ?? createRegistry(options.registryOptions);
|
|
404
|
+
const queue = options.queue ?? createPerLaunchQueue();
|
|
405
|
+
const onShutdown = options.onShutdown;
|
|
406
|
+
const server = Bun.serve({
|
|
407
|
+
port: options.port ?? 0,
|
|
408
|
+
hostname: options.hostname ?? "127.0.0.1",
|
|
409
|
+
fetch: (request) => handle(request, registry, queue, onShutdown)
|
|
410
|
+
});
|
|
411
|
+
const repoContextRefresh = startRepoContextRefresh(registry, options.repoContextRefreshIntervalMs ?? DEFAULT_REPO_CONTEXT_REFRESH_INTERVAL_MS);
|
|
412
|
+
const port = server.port ?? options.port ?? 0;
|
|
413
|
+
const url = `http://${server.hostname}:${port}`;
|
|
414
|
+
const statePath = options.statePath ?? daemonStatePath();
|
|
415
|
+
writeDaemonState({
|
|
416
|
+
version: 1,
|
|
417
|
+
url,
|
|
418
|
+
pid: process.pid,
|
|
419
|
+
started_at: new Date().toISOString()
|
|
420
|
+
}, statePath);
|
|
421
|
+
return {
|
|
422
|
+
registry,
|
|
423
|
+
url,
|
|
424
|
+
port,
|
|
425
|
+
async stop() {
|
|
426
|
+
repoContextRefresh.stop();
|
|
427
|
+
await server.stop(true);
|
|
428
|
+
removeDaemonState(statePath);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function startRepoContextRefresh(registry, intervalMs) {
|
|
433
|
+
let timer = null;
|
|
434
|
+
const stopTimer = () => {
|
|
435
|
+
if (!timer)
|
|
436
|
+
return;
|
|
437
|
+
clearInterval(timer);
|
|
438
|
+
timer = null;
|
|
439
|
+
};
|
|
440
|
+
const startTimer = () => {
|
|
441
|
+
if (timer || registry.list().length === 0)
|
|
442
|
+
return;
|
|
443
|
+
timer = setInterval(() => {
|
|
444
|
+
registry.refreshRepoContext();
|
|
445
|
+
}, intervalMs);
|
|
446
|
+
};
|
|
447
|
+
const unsubscribe = registry.subscribe((event) => {
|
|
448
|
+
if (event.type === "created")
|
|
449
|
+
startTimer();
|
|
450
|
+
if (event.type === "removed" && registry.list().length === 0)
|
|
451
|
+
stopTimer();
|
|
452
|
+
});
|
|
453
|
+
startTimer();
|
|
454
|
+
return {
|
|
455
|
+
stop() {
|
|
456
|
+
unsubscribe();
|
|
457
|
+
stopTimer();
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
async function handle(request, registry, queue, onShutdown) {
|
|
462
|
+
const url = new URL(request.url);
|
|
463
|
+
const method = request.method.toUpperCase();
|
|
464
|
+
const path = url.pathname;
|
|
465
|
+
if (method === "GET" && path === "/healthz")
|
|
466
|
+
return json({ ok: true });
|
|
467
|
+
if (method === "GET" && path === "/agents") {
|
|
468
|
+
let agents = registry.list();
|
|
469
|
+
for (const key of AGENT_FILTER_KEYS) {
|
|
470
|
+
if (url.searchParams.has(key)) {
|
|
471
|
+
const value = url.searchParams.get(key);
|
|
472
|
+
const expected = value === "" ? null : value;
|
|
473
|
+
agents = agents.filter((a) => a[key] === expected);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return json({ agents });
|
|
477
|
+
}
|
|
478
|
+
if (method === "POST" && path === "/shutdown") {
|
|
479
|
+
setTimeout(() => onShutdown?.(), 0);
|
|
480
|
+
return json({ ok: true });
|
|
481
|
+
}
|
|
482
|
+
if (method === "POST" && path === "/launches") {
|
|
483
|
+
const reg = await readLaunchRegistration(request);
|
|
484
|
+
if (!reg)
|
|
485
|
+
return text(400, "invalid launch registration");
|
|
486
|
+
const record = registry.register(reg);
|
|
487
|
+
return json(record, { status: 201 });
|
|
488
|
+
}
|
|
489
|
+
const launchPatch = method === "PATCH" && path.match(/^\/launches\/(\d+)$/);
|
|
490
|
+
if (launchPatch) {
|
|
491
|
+
const id = Number(launchPatch[1]);
|
|
492
|
+
const patch = await readLaunchUpdate(request);
|
|
493
|
+
if (!patch)
|
|
494
|
+
return text(400, "invalid launch update");
|
|
495
|
+
const updated = registry.update(id, patch);
|
|
496
|
+
if (!updated)
|
|
497
|
+
return text(404, "launch not found");
|
|
498
|
+
return json(updated);
|
|
499
|
+
}
|
|
500
|
+
const launchDelete = method === "DELETE" && path.match(/^\/launches\/(\d+)$/);
|
|
501
|
+
if (launchDelete) {
|
|
502
|
+
const id = Number(launchDelete[1]);
|
|
503
|
+
const removed = registry.remove(id);
|
|
504
|
+
if (!removed)
|
|
505
|
+
return text(404, "launch not found");
|
|
506
|
+
return json({ ok: true });
|
|
507
|
+
}
|
|
508
|
+
const hookMatch = method === "POST" && path.match(/^\/hooks\/(claude|codex|agent)$/);
|
|
509
|
+
if (hookMatch) {
|
|
510
|
+
const agent = hookMatch[1];
|
|
511
|
+
if (!agent || !isSupportedAgent(agent))
|
|
512
|
+
return text(400, "unsupported agent");
|
|
513
|
+
const launchId = Number(url.searchParams.get("launch_id"));
|
|
514
|
+
if (!Number.isInteger(launchId) || launchId <= 0) {
|
|
515
|
+
return text(400, "missing or invalid launch_id");
|
|
516
|
+
}
|
|
517
|
+
if (!registry.get(launchId)) {
|
|
518
|
+
return text(202, "unknown launch");
|
|
519
|
+
}
|
|
520
|
+
const event = url.searchParams.get("event") ?? "unknown";
|
|
521
|
+
const body = await request.text();
|
|
522
|
+
const payload2 = parseJson(body);
|
|
523
|
+
await queue.enqueue(launchId, () => {
|
|
524
|
+
const normalized = normalizeHook(agent, event, payload2);
|
|
525
|
+
registry.applyHook(launchId, agent, normalized);
|
|
526
|
+
});
|
|
527
|
+
return json({ ok: true });
|
|
528
|
+
}
|
|
529
|
+
return text(404, "not found");
|
|
530
|
+
}
|
|
531
|
+
async function readLaunchRegistration(request) {
|
|
532
|
+
const data = await readJson(request);
|
|
533
|
+
if (!data)
|
|
534
|
+
return null;
|
|
535
|
+
if (typeof data.agent !== "string" || !data.agent)
|
|
536
|
+
return null;
|
|
537
|
+
if (typeof data.folder !== "string" || !data.folder)
|
|
538
|
+
return null;
|
|
539
|
+
return {
|
|
540
|
+
agent: data.agent,
|
|
541
|
+
folder: data.folder,
|
|
542
|
+
title: typeof data.title === "string" ? data.title : null,
|
|
543
|
+
nvim: {
|
|
544
|
+
server: typeof data.nvim_server === "string" ? data.nvim_server : null,
|
|
545
|
+
terminalBufnr: typeof data.nvim_terminal_bufnr === "string" ? data.nvim_terminal_bufnr : null
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
async function readLaunchUpdate(request) {
|
|
550
|
+
const data = await readJson(request);
|
|
551
|
+
if (!data)
|
|
552
|
+
return null;
|
|
553
|
+
const patch = {};
|
|
554
|
+
if ("agent_process_pid" in data) {
|
|
555
|
+
const value = data.agent_process_pid;
|
|
556
|
+
patch.agent_process_pid = typeof value === "number" ? value : null;
|
|
557
|
+
}
|
|
558
|
+
if (typeof data.state === "string")
|
|
559
|
+
patch.state = data.state;
|
|
560
|
+
if ("title" in data) {
|
|
561
|
+
patch.title = typeof data.title === "string" ? data.title : null;
|
|
562
|
+
}
|
|
563
|
+
return patch;
|
|
564
|
+
}
|
|
565
|
+
async function readJson(request) {
|
|
566
|
+
try {
|
|
567
|
+
const data = await request.json();
|
|
568
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
569
|
+
return data;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// packages/agent-watchd/src/index.ts
|
|
578
|
+
function readPort() {
|
|
579
|
+
const raw = process.env[ENV.DAEMON_PORT];
|
|
580
|
+
if (!raw)
|
|
581
|
+
return DEFAULT_DAEMON_PORT;
|
|
582
|
+
const parsed = Number(raw);
|
|
583
|
+
if (!Number.isInteger(parsed) || parsed < 0)
|
|
584
|
+
return DEFAULT_DAEMON_PORT;
|
|
585
|
+
return parsed;
|
|
586
|
+
}
|
|
587
|
+
function main() {
|
|
588
|
+
let stop = async () => {};
|
|
589
|
+
const handle2 = startDaemon({
|
|
590
|
+
port: readPort(),
|
|
591
|
+
hostname: process.env[ENV.DAEMON_HOST] ?? DEFAULT_DAEMON_HOST,
|
|
592
|
+
onShutdown: () => stop()
|
|
593
|
+
});
|
|
594
|
+
process.stdout.write(`agent-watchd listening on ${handle2.url}
|
|
595
|
+
`);
|
|
596
|
+
stop = async () => {
|
|
597
|
+
await handle2.stop();
|
|
598
|
+
process.exit(0);
|
|
599
|
+
};
|
|
600
|
+
process.on("SIGINT", stop);
|
|
601
|
+
process.on("SIGTERM", stop);
|
|
602
|
+
}
|
|
603
|
+
main();
|