Layer 2 MCP Watcher v0 scaffold

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.
This commit is contained in:
bob 2026-05-06 17:44:57 -03:00
parent 3eda72df28
commit c22558c67a
18 changed files with 4786 additions and 0 deletions

View file

@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
readInbox,
readHwm,
writeHwm,
unreadSinceHwm,
DEFER_LIMIT,
type PingEvent,
type HwmState,
} from "../src/inbox.js";
const ev = (over: Partial<PingEvent>): PingEvent => ({
ts: "2026-05-06T10:00:00Z",
id: "ping-aaa",
from: "foreman",
to: "bob",
type: "INFO",
payload: "test",
...over,
});
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "watcher-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("readInbox", () => {
it("returns empty for missing file", () => {
expect(readInbox(join(dir, "missing.inbox"))).toEqual([]);
});
it("parses JSONL, skips blank + malformed lines", () => {
const path = join(dir, "x.inbox");
writeFileSync(
path,
[
JSON.stringify(ev({ id: "ping-1" })),
"",
"{ not json",
JSON.stringify(ev({ id: "ping-2", ts: "2026-05-06T11:00:00Z" })),
"",
].join("\n"),
);
const r = readInbox(path);
expect(r.map((e) => e.id)).toEqual(["ping-1", "ping-2"]);
});
it("requires id and ts on each line", () => {
const path = join(dir, "x.inbox");
writeFileSync(path, [
JSON.stringify({ ts: "x", payload: "missing id" }),
JSON.stringify({ id: "missing-ts", payload: "x" }),
JSON.stringify(ev({ id: "good" })),
].join("\n"));
expect(readInbox(path).map((e) => e.id)).toEqual(["good"]);
});
});
describe("readHwm + writeHwm", () => {
it("returns empty state for missing file", () => {
const r = readHwm(join(dir, "missing.hwm"));
expect(r).toEqual({ last_delivered_ts: "", pending_attempts: {} });
});
it("round-trips through atomic write", () => {
const path = join(dir, ".hwm");
const state: HwmState = {
last_delivered_ts: "2026-05-06T12:00:00Z",
pending_attempts: { "ping-x": 2 },
};
writeHwm(path, state);
expect(existsSync(path)).toBe(true);
expect(existsSync(path + ".tmp")).toBe(false);
expect(readHwm(path)).toEqual(state);
});
it("recovers from corrupt file with empty state", () => {
const path = join(dir, ".hwm");
writeFileSync(path, "{ corrupt");
expect(readHwm(path)).toEqual({ last_delivered_ts: "", pending_attempts: {} });
});
});
describe("unreadSinceHwm", () => {
const yes = () => true;
const no = () => false;
it("returns all events when HWM is empty", () => {
const events = [ev({ id: "a", ts: "2026-05-06T10:00:00Z" })];
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, yes);
expect(r.deliverable.map((d) => d.event.id)).toEqual(["a"]);
expect(r.deferred).toEqual([]);
expect(r.nextHwm.last_delivered_ts).toBe("2026-05-06T10:00:00Z");
});
it("filters out events with ts <= HWM", () => {
const events = [
ev({ id: "old", ts: "2026-05-06T09:00:00Z" }),
ev({ id: "new", ts: "2026-05-06T11:00:00Z" }),
];
const r = unreadSinceHwm(
events,
{ last_delivered_ts: "2026-05-06T10:00:00Z", pending_attempts: {} },
yes,
);
expect(r.deliverable.map((d) => d.event.id)).toEqual(["new"]);
});
it("defers events whose sentinel is missing, increments attempts", () => {
const events = [ev({ id: "a", sentinel: "/nope" })];
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, no);
expect(r.deliverable).toEqual([]);
expect(r.deferred.map((e) => e.id)).toEqual(["a"]);
expect(r.nextHwm.pending_attempts).toEqual({ a: 1 });
// HWM does NOT advance past the deferred event
expect(r.nextHwm.last_delivered_ts).toBe("");
});
it("delivers with warning on the DEFER_LIMIT-th attempt", () => {
const events = [ev({ id: "a", sentinel: "/nope" })];
const r = unreadSinceHwm(
events,
{ last_delivered_ts: "", pending_attempts: { a: DEFER_LIMIT - 1 } },
no,
);
expect(r.deliverable.length).toBe(1);
expect(r.deliverable[0].warning).toMatch(/hasn't synced/);
expect(r.deferred).toEqual([]);
// attempts cleared once delivered
expect(r.nextHwm.pending_attempts).toEqual({});
expect(r.nextHwm.last_delivered_ts).toBe("2026-05-06T10:00:00Z");
});
it("delivers immediately when sentinel exists", () => {
const events = [ev({ id: "a", sentinel: "/exists" })];
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, yes);
expect(r.deliverable.length).toBe(1);
expect(r.deliverable[0].warning).toBeUndefined();
expect(r.nextHwm.pending_attempts).toEqual({});
});
it("HWM does not advance past deferred event even when later events deliver", () => {
const events = [
ev({ id: "deferred", ts: "2026-05-06T10:00:00Z", sentinel: "/nope" }),
ev({ id: "later", ts: "2026-05-06T11:00:00Z" }),
];
const sentinelExists = (p: string) => p !== "/nope";
const r = unreadSinceHwm(
events,
{ last_delivered_ts: "", pending_attempts: {} },
sentinelExists,
);
expect(r.deliverable.map((d) => d.event.id)).toEqual(["later"]);
expect(r.deferred.map((e) => e.id)).toEqual(["deferred"]);
// HWM stays empty so the deferred event is reconsidered next read
expect(r.nextHwm.last_delivered_ts).toBe("");
});
it("clears pending_attempts when sentinel finally lands", () => {
const events = [ev({ id: "a", sentinel: "/now-ok" })];
const r = unreadSinceHwm(
events,
{ last_delivered_ts: "", pending_attempts: { a: 2 } },
yes,
);
expect(r.deliverable.length).toBe(1);
expect(r.nextHwm.pending_attempts).toEqual({});
});
});