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
65
mcp-watcher/test/identity.test.ts
Normal file
65
mcp-watcher/test/identity.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveIdentity } from "../src/identity.js";
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "watcher-id-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveIdentity", () => {
|
||||
it("uses PING_AGENT_IDENTITY env var first", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "default-agent\n");
|
||||
const r = resolveIdentity({
|
||||
HOME: dir,
|
||||
PING_AGENT_IDENTITY: "envname",
|
||||
});
|
||||
expect(r.agent).toBe("envname");
|
||||
expect(r.source).toMatch(/env/i);
|
||||
});
|
||||
|
||||
it("uses $CLAUDE_HOME/ping-agent next", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "default-agent\n");
|
||||
const claudeHome = join(dir, "claude-sandbox");
|
||||
mkdirSync(claudeHome);
|
||||
writeFileSync(join(claudeHome, "ping-agent"), "sandbox\n");
|
||||
const r = resolveIdentity({
|
||||
HOME: dir,
|
||||
CLAUDE_HOME: claudeHome,
|
||||
PING_AGENT_IDENTITY: "",
|
||||
});
|
||||
expect(r.agent).toBe("sandbox");
|
||||
expect(r.source).toContain(claudeHome);
|
||||
});
|
||||
|
||||
it("falls through to ~/.ping-agent", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "bob\n");
|
||||
const r = resolveIdentity({ HOME: dir });
|
||||
expect(r.agent).toBe("bob");
|
||||
expect(r.source).toContain(".ping-agent");
|
||||
});
|
||||
|
||||
it("throws when nothing resolves", () => {
|
||||
expect(() => resolveIdentity({ HOME: dir })).toThrow(/no identity/);
|
||||
});
|
||||
|
||||
it("ignores empty CLAUDE_HOME/ping-agent and falls through", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "bob\n");
|
||||
const claudeHome = join(dir, "claude-sandbox");
|
||||
mkdirSync(claudeHome);
|
||||
writeFileSync(join(claudeHome, "ping-agent"), "");
|
||||
const r = resolveIdentity({ HOME: dir, CLAUDE_HOME: claudeHome });
|
||||
expect(r.agent).toBe("bob");
|
||||
});
|
||||
|
||||
it("trims whitespace from identity files", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), " bob \n\n");
|
||||
const r = resolveIdentity({ HOME: dir });
|
||||
expect(r.agent).toBe("bob");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue