Per spec/agent-watcher.md §4. TypeScript/Node implementation living in
mcp-watcher/ subdirectory, parallel to Layer 1 Collector at repo root.
What lands:
- Core MCP server (src/server.ts) with experimental['claude/channel']:{}
+ tools:{} capability declarations, stdio transport, channel-event
notifier wired through the inbox watcher.
- Identity resolution mirroring agent-ping's layered model
(PING_AGENT_IDENTITY env, $CLAUDE_HOME/ping-agent, ~/.ping-agent).
- Inbox reader with HWM tracking, sentinel deferral (warn-after-3),
atomic HWM writes via tmp+rename.
- chokidar-backed file watcher with coalesced drain, urgent-first
ordering, recentEvents map for tool sender lookup.
- Three reply tools (ack / respond / mark_handled) with cross-host
write discipline (writes to local inbox files; Syncthing replicates).
- Sentinel file (.<agent>.watcher-active) for hook coexistence per
spec §4.3 — agent-ping hook stands down when the watcher is in
charge of delivery on this host. Sentinel + hwm in .stignore.
- 35 unit tests passing (vitest): inbox parsing, HWM round-trip,
sentinel deferral semantics, identity layers, tool I/O, watcher
drain + ordering + restart-from-hwm.
- install.sh (Angus-executed, rule-2 compliant) installs deps,
builds, symlinks ~/.local/bin/agent-watcher-mcp, prints mcp.json
registration snippet for paste.
- README documents launch flag, sandbox CLAUDE_HOME pattern,
hook coexistence, observability, v2 limitations.
Not yet:
- Integration test against a real Claude Code session — gated on
Angus spinning up a sandbox CC session on the VPS with
CLAUDE_HOME=~/.claude-sandbox.
- agent-ping hook update to read the sentinel and stand down.
Separate small PR against agent-ping.
Interface contract with Layer 1: the inbox JSONL line shape from
inbox.ts::PingEvent matches inbox.Event in the Collector — bit-
identical reads regardless of source.
41 lines
1.2 KiB
TypeScript
41 lines
1.2 KiB
TypeScript
// Sentinel file: signals the agent-ping UserPromptSubmit hook to stand
|
|
// down while the watcher is delivering pings via Channels (spec §4.3).
|
|
//
|
|
// The file lives at `pings/.<agent>.watcher-active` and is local-only
|
|
// (must be in .stignore — otherwise one host's watcher silences another
|
|
// host's hook).
|
|
//
|
|
// Lifecycle:
|
|
// - On startup: write the file with the current PID + timestamp.
|
|
// - On graceful shutdown (SIGINT/SIGTERM): remove it.
|
|
// - On crash: file is left behind. The hook checks file age + PID
|
|
// liveness to ignore stale sentinels. (See agent-ping hook update.)
|
|
|
|
import { writeFileSync, unlinkSync, mkdirSync, existsSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
export interface SentinelHandle {
|
|
release(): void;
|
|
}
|
|
|
|
export function claimSentinel(path: string): SentinelHandle {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const body = JSON.stringify({
|
|
pid: process.pid,
|
|
started_at: new Date().toISOString(),
|
|
});
|
|
writeFileSync(path, body + "\n", "utf8");
|
|
|
|
let released = false;
|
|
return {
|
|
release() {
|
|
if (released) return;
|
|
released = true;
|
|
try {
|
|
if (existsSync(path)) unlinkSync(path);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
},
|
|
};
|
|
}
|