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.
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
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({});
|
|
});
|
|
});
|