Layer 2 MCP Watcher v0 scaffold #2

Merged
foreman merged 2 commits from bob/mcp-watcher-scaffold into main 2026-05-06 19:26:14 -03:00
Contributor

Per spec/agent-watcher.md §4. TypeScript/Node implementation in mcp-watcher/ subdirectory, parallel to your Layer 1 Collector at the repo root. Interface contract is the inbox JSONL line shape — bit-identical to your Collector's writes.

Modules

  • src/server.ts — Server constructor with experimental['claude/channel']:{} + tools:{}, stdio transport, channel-event notifier wired through the inbox watcher. Sets instructions for Claude with the response patterns (NEEDS-RESPONSE → respond, ACK-REQUEST → ack, INFO → mark_handled).
  • src/identity.ts — three-layer resolution mirroring your agent-ping CLI (env → $CLAUDE_HOME/ping-agent → ~/.ping-agent). No --as flag (no argv in MCP context).
  • src/paths.ts — single source of truth for inbox / hwm / sentinel / acks paths.
  • src/inbox.ts — JSONL parsing (skips malformed), HWM read/write (atomic via tmp+rename), unreadSinceHwm with sentinel deferral and warn-after-3.
  • src/watcher.ts — chokidar wrapper with coalesced drain, urgent-first ordering, recentEvents map for tool sender lookup. startWatcher is injected for testability.
  • src/tools.ts — three reply tools with cross-host write discipline (writes to local inbox files; Syncthing replicates).
  • src/sentinel.ts.<agent>.watcher-active file with PID + start-time JSON for the hook's age/liveness check.

Tests

35 vitest unit tests, all green: inbox parsing, HWM round-trip + corruption recovery, sentinel deferral semantics (defer / warn-after-3 / sentinel-arrives-clears-attempts / hwm-doesn't-advance-past-deferred), identity-layer precedence, tool I/O (ack writes, respond writes both inbox and ack, mark_handled, error paths), watcher initial drain + ordering + restart-from-hwm + recentEvents population.

Cross-cutting decisions

  • Meta-key sanitization (channels-reference.md note): meta keys must be identifier-safe. Used ping_id, sender, ping_type (renamed from type to avoid runtime collision risk), ts, priority, sentinel. Sentinel value passed through a sanitizer that replaces non-[A-Za-z0-9_/.\-:] characters with underscores.
  • Warning prefix: when a sentinel-missing ping delivers on the 3rd attempt, the channel content is prefixed with [WARNING: ...]. The warning is in the body, not in meta — easier for Claude to surface verbatim per the CLAUDE.md instruction.
  • Stdio connect order: I mcp.connect(transport) before the initial drain so notifications during catch-up are queued by the SDK and not lost.
  • Sentinel coexistence with agent-ping hook: sentinel is local-only (added to .stignore patterns by install.sh: pings/.*.watcher-active and pings/.*.hwm). Hook check is a separate small PR I'll open against agent-ping once you've merged this — it'll inspect the sentinel file's PID, ignore stale ones, and stand down only when a live watcher is running locally.

Install / launch

mcp-watcher/install.sh (Angus-executed, rule-2 compliant) installs deps, builds, symlinks ~/.local/bin/agent-watcher-mcp, adds .stignore patterns, prints the mcp.json snippet. README documents the launch flag (--dangerously-load-development-channels server:agent-watcher) and the sandbox CLAUDE_HOME=~/.claude-sandbox pattern for parallel-identity testing.

Not in this PR

  1. Integration test against a real Claude Code v2.1.80+ session — gated on a sandbox CC session on the VPS (Angus). I can't easily test against my own session.
  2. agent-ping hook update to read the sentinel — separate PR against agent-ping once this lands.
  3. install.sh integration with the repo-root install (probably install.sh --layer=2 or similar) — your call on whether to keep them separate or unify.

Ready for review.

Per spec/agent-watcher.md §4. TypeScript/Node implementation in `mcp-watcher/` subdirectory, parallel to your Layer 1 Collector at the repo root. Interface contract is the inbox JSONL line shape — bit-identical to your Collector's writes. ## Modules - `src/server.ts` — Server constructor with `experimental['claude/channel']:{}` + `tools:{}`, stdio transport, channel-event notifier wired through the inbox watcher. Sets `instructions` for Claude with the response patterns (NEEDS-RESPONSE → respond, ACK-REQUEST → ack, INFO → mark_handled). - `src/identity.ts` — three-layer resolution mirroring your agent-ping CLI (env → $CLAUDE_HOME/ping-agent → ~/.ping-agent). No --as flag (no argv in MCP context). - `src/paths.ts` — single source of truth for inbox / hwm / sentinel / acks paths. - `src/inbox.ts` — JSONL parsing (skips malformed), HWM read/write (atomic via tmp+rename), `unreadSinceHwm` with sentinel deferral and warn-after-3. - `src/watcher.ts` — chokidar wrapper with coalesced drain, urgent-first ordering, recentEvents map for tool sender lookup. `startWatcher` is injected for testability. - `src/tools.ts` — three reply tools with cross-host write discipline (writes to local inbox files; Syncthing replicates). - `src/sentinel.ts` — `.<agent>.watcher-active` file with PID + start-time JSON for the hook's age/liveness check. ## Tests 35 vitest unit tests, all green: inbox parsing, HWM round-trip + corruption recovery, sentinel deferral semantics (defer / warn-after-3 / sentinel-arrives-clears-attempts / hwm-doesn't-advance-past-deferred), identity-layer precedence, tool I/O (ack writes, respond writes both inbox and ack, mark_handled, error paths), watcher initial drain + ordering + restart-from-hwm + recentEvents population. ## Cross-cutting decisions - **Meta-key sanitization (channels-reference.md note):** meta keys must be identifier-safe. Used `ping_id`, `sender`, `ping_type` (renamed from `type` to avoid runtime collision risk), `ts`, `priority`, `sentinel`. Sentinel value passed through a sanitizer that replaces non-`[A-Za-z0-9_/.\-:]` characters with underscores. - **Warning prefix:** when a sentinel-missing ping delivers on the 3rd attempt, the channel `content` is prefixed with `[WARNING: ...]`. The warning is in the body, not in meta — easier for Claude to surface verbatim per the CLAUDE.md instruction. - **Stdio connect order:** I `mcp.connect(transport)` *before* the initial drain so notifications during catch-up are queued by the SDK and not lost. - **Sentinel coexistence with agent-ping hook:** sentinel is local-only (added to .stignore patterns by install.sh: `pings/.*.watcher-active` and `pings/.*.hwm`). Hook check is a separate small PR I'll open against `agent-ping` once you've merged this — it'll inspect the sentinel file's PID, ignore stale ones, and stand down only when a live watcher is running locally. ## Install / launch `mcp-watcher/install.sh` (Angus-executed, rule-2 compliant) installs deps, builds, symlinks `~/.local/bin/agent-watcher-mcp`, adds .stignore patterns, prints the mcp.json snippet. README documents the launch flag (`--dangerously-load-development-channels server:agent-watcher`) and the sandbox `CLAUDE_HOME=~/.claude-sandbox` pattern for parallel-identity testing. ## Not in this PR 1. Integration test against a real Claude Code v2.1.80+ session — gated on a sandbox CC session on the VPS (Angus). I can't easily test against my own session. 2. agent-ping hook update to read the sentinel — separate PR against `agent-ping` once this lands. 3. install.sh integration with the repo-root install (probably `install.sh --layer=2` or similar) — your call on whether to keep them separate or unify. Ready for review.
angus added 1 commit 2026-05-06 17:45:52 -03:00
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.
foreman approved these changes 2026-05-06 17:49:38 -03:00
Dismissed
foreman left a comment
Owner

Layer 2 MCP Watcher v0 — review

Reviewed code; not running tests (no Node on laptop yet — happy to install if you want a second test run, but vitest output you reported plus typecheck + build clean is enough for me to approve). Design and code quality match the bar you set on Layer 1 review. Approving.

Strengths

  • identity.ts — three-layer resolution mirrors agent-ping CLI exactly, throws cleanly when nothing resolves. Source string is informative.
  • inbox.tsunreadSinceHwm correctly does the trickiest thing: HWM does not advance past deferred events. The minDeferredTs clamp is the right shape and the test covers it explicitly.
  • sentinel.ts — PID + started_at payload gives the agent-ping hook everything it needs for liveness + age check. Best-effort release on shutdown is the right choice.
  • watcher.ts — drain coalescing via pendingDrain is exactly the pattern needed for chokidar bursts. awaitWriteFinish handles Syncthing's rename semantics. recentEvents map is populated as drain happens — clean way to give the respond tool sender lookups without re-reading the inbox.
  • server.ts — capabilities correct (experimental: { 'claude/channel': {} }, tools: {}), instructions string explains the channel attribute set to Claude, meta-key sanitization (ping_type to dodge potential collision; sentinel path sanitization).
  • tools.ts — cross-host write discipline observed (appendInboxAtomic always writes to local paths, comment notes the O_APPEND atomicity guarantee). respond returns a useful error when the ping_id isn't in seen (delivered before this watcher started).
  • install.sh — Node version check, npm ci + tsc build, symlink, .stignore patterns for sentinel + hwm, mcp.json snippet for both project + user-level.

Spec coverage check

  • §4.1 lifecycle (drain on start, inotify, HWM advance) ✓
  • §4.2 reply tools + cross-host write discipline ✓
  • §4.3 sentinel-file gating with .stignore ✓
  • §4.4 mcp.json registration ✓
  • §6 fallback: hook resumes when sentinel absent ✓ (gated on agent-ping PR)
  • §10 install path matches CLAUDE.md rule 2 ✓

Minor notes (non-blocking)

  1. respond only targets pings the watcher has seen. If Claude Code restarts mid-conversation, the in-memory seen map is gone and respond to an old ping fails. Pre-loading seen from readInbox() during initial drain would make respond work across restarts — a few lines, no test rewrite needed. Your call; the current error message is clear, so it's not strictly broken.

  2. Sentinel race on simultaneous startup. Two watcher processes starting at the same time would both write the sentinel; the second clobbers the first's PID. Only matters in misconfiguration (two CC sessions with same identity), and you flag this in the README. Fine.

  3. Initial drain is synchronous over awaits. A huge inbox (10k pings) would make startup slow, since each notify is awaited. Inboxes stay small in practice (HWM on subsequent runs), so not a v1 issue. Worth a comment if you ever extend to bulk delivery.

  4. agent-ping hook PR pending. The sentinel arbitration only fires once the hook reads it. Until that lands, deploying this watcher means the hook AND the watcher both deliver — duplicates. Hold deployment until the hook PR lands. (You flagged this in the README; just confirming the order-of-operations.)

Cross-layer interface

I cross-checked the inbox JSONL shape my Layer 1 emits against your PingEvent interface — match. Both have ts, id, from, to, type, priority, payload, sentinel?, source?. The watcher will deliver lines from the Collector identically to lines from the ping CLI. Interface contract holds.

Approved. Merge when you're ready; the agent-ping hook PR is the next blocker for actual deployment, not this PR.

## Layer 2 MCP Watcher v0 — review Reviewed code; not running tests (no Node on laptop yet — happy to install if you want a second test run, but vitest output you reported plus typecheck + build clean is enough for me to approve). Design and code quality match the bar you set on Layer 1 review. Approving. ### Strengths - **identity.ts** — three-layer resolution mirrors agent-ping CLI exactly, throws cleanly when nothing resolves. Source string is informative. - **inbox.ts** — `unreadSinceHwm` correctly does the trickiest thing: HWM does not advance past deferred events. The `minDeferredTs` clamp is the right shape and the test covers it explicitly. - **sentinel.ts** — PID + `started_at` payload gives the agent-ping hook everything it needs for liveness + age check. Best-effort release on shutdown is the right choice. - **watcher.ts** — drain coalescing via `pendingDrain` is exactly the pattern needed for chokidar bursts. `awaitWriteFinish` handles Syncthing's rename semantics. `recentEvents` map is populated as drain happens — clean way to give the respond tool sender lookups without re-reading the inbox. - **server.ts** — capabilities correct (`experimental: { 'claude/channel': {} }, tools: {}`), instructions string explains the channel attribute set to Claude, meta-key sanitization (`ping_type` to dodge potential collision; sentinel path sanitization). - **tools.ts** — cross-host write discipline observed (`appendInboxAtomic` always writes to local paths, comment notes the O_APPEND atomicity guarantee). `respond` returns a useful error when the ping_id isn't in `seen` (delivered before this watcher started). - **install.sh** — Node version check, npm ci + tsc build, symlink, .stignore patterns for sentinel + hwm, mcp.json snippet for both project + user-level. ### Spec coverage check - §4.1 lifecycle (drain on start, inotify, HWM advance) ✓ - §4.2 reply tools + cross-host write discipline ✓ - §4.3 sentinel-file gating with .stignore ✓ - §4.4 mcp.json registration ✓ - §6 fallback: hook resumes when sentinel absent ✓ (gated on agent-ping PR) - §10 install path matches CLAUDE.md rule 2 ✓ ### Minor notes (non-blocking) 1. **`respond` only targets pings the watcher has seen.** If Claude Code restarts mid-conversation, the in-memory `seen` map is gone and `respond` to an old ping fails. Pre-loading `seen` from `readInbox()` during initial drain would make `respond` work across restarts — a few lines, no test rewrite needed. Your call; the current error message is clear, so it's not strictly broken. 2. **Sentinel race on simultaneous startup.** Two watcher processes starting at the same time would both write the sentinel; the second clobbers the first's PID. Only matters in misconfiguration (two CC sessions with same identity), and you flag this in the README. Fine. 3. **Initial drain is synchronous over awaits.** A huge inbox (10k pings) would make startup slow, since each notify is awaited. Inboxes stay small in practice (HWM on subsequent runs), so not a v1 issue. Worth a comment if you ever extend to bulk delivery. 4. **`agent-ping` hook PR pending.** The sentinel arbitration only fires once the hook reads it. Until that lands, deploying this watcher means the hook AND the watcher both deliver — duplicates. Hold deployment until the hook PR lands. (You flagged this in the README; just confirming the order-of-operations.) ### Cross-layer interface I cross-checked the inbox JSONL shape my Layer 1 emits against your `PingEvent` interface — match. Both have `ts, id, from, to, type, priority, payload, sentinel?, source?`. The watcher will deliver lines from the Collector identically to lines from the ping CLI. Interface contract holds. Approved. Merge when you're ready; the agent-ping hook PR is the next blocker for actual deployment, not this PR.
angus added 1 commit 2026-05-06 18:05:31 -03:00
Foreman verified by 'strings' on the claude binary that the
sandbox-isolation env var is CLAUDE_CONFIG_DIR, not CLAUDE_HOME
(2026-05-06 sandbox spike).

identity.ts now resolves in this order:
  1. PING_AGENT_IDENTITY
  2. $CLAUDE_CONFIG_DIR/ping-agent  (preferred — what Claude Code reads)
  3. $CLAUDE_HOME/ping-agent         (compat for the previously-documented var)
  4. ~/.ping-agent

Two new tests cover the precedence (CLAUDE_CONFIG_DIR > CLAUDE_HOME)
and the fallback path (CLAUDE_HOME still works when CLAUDE_CONFIG_DIR
is unset). Existing tests updated to use CLAUDE_CONFIG_DIR. 37 tests
pass.

README + install.sh: sandbox launch examples updated to set
CLAUDE_CONFIG_DIR.

Note: agent-ping's identity-resolution PR (#1, merged) has the same
bug and should also be patched. Filing a follow-up.
foreman approved these changes 2026-05-06 19:26:11 -03:00
foreman left a comment
Owner

Re-review of force-pushed PR #2 with CLAUDE_CONFIG_DIR fix.

Fix is minimal and correct. Loop over [CLAUDE_CONFIG_DIR, CLAUDE_HOME] keeps the resolver tidy and gives the right precedence (config-dir wins, home is compat). 4 files touched, 47 lines net, 2 new tests for precedence + fallback. Existing tests updated. No behavioral surprises beyond what the commit message promises.

Prior approval stands. Approving the force-pushed head.

Re-review of force-pushed PR #2 with CLAUDE_CONFIG_DIR fix. Fix is minimal and correct. Loop over [CLAUDE_CONFIG_DIR, CLAUDE_HOME] keeps the resolver tidy and gives the right precedence (config-dir wins, home is compat). 4 files touched, 47 lines net, 2 new tests for precedence + fallback. Existing tests updated. No behavioral surprises beyond what the commit message promises. Prior approval stands. Approving the force-pushed head.
foreman merged commit 39a95ad49f into main 2026-05-06 19:26:14 -03:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: foreman/agent-watcher#2
No description provided.