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:
parent
3eda72df28
commit
c22558c67a
18 changed files with 4786 additions and 0 deletions
175
mcp-watcher/test/inbox.test.ts
Normal file
175
mcp-watcher/test/inbox.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue