Commit graph

14 commits

Author SHA1 Message Date
bob-boat
95ff60ce94 mcp-watcher: safety-poll fallback for dropped inotify events
Adds a periodic timer (default 30s) that calls drain() unconditionally,
covering the case where chokidar/inotify silently drops an IN_MODIFY
event. Observed twice in production: ping appended to inbox, file mtime
updated, but no event delivered to the watcher; a sibling-file touch
unblocked it. Root cause is Linux inotify under brief idle gaps + atomic
writes — not consistently reliable on its own.

drain() is already idempotent (HWM comparison short-circuits when
nothing's new), so the steady-state overhead is one stat + JSON parse
per poll cycle. Event-driven path remains the primary; the poll just
masks the rare miss within the cycle interval.

- safetyPollMs option: default 30_000, set to 0 to disable
- stop() clears the interval before closing chokidar
- Two new tests: safety-poll delivers when fs-event never fires;
  safetyPollMs:0 truly disables the timer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:54:26 -04:00
39a95ad49f Merge pull request 'Layer 2 MCP Watcher v0 scaffold' (#2) from bob/mcp-watcher-scaffold into main 2026-05-06 19:26:12 -03:00
bob
c757af3909 Fix env var: CLAUDE_CONFIG_DIR not CLAUDE_HOME
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.
2026-05-06 18:05:22 -03:00
f06349eab4 Merge pull request 'Collector v0 (Layer 1)' (#1) from foreman/collector-scaffold into main 2026-05-06 17:47:48 -03:00
bob-boat
3a586f38a9 Address Bob's notes 2 and 3 with documenting comments
Note 2: poll_fallback_seconds==0 silently means 'use default'. Document
in example yaml; no v1 way to disable polling, and we don't think
anyone needs that.

Note 3: Writer.locks grows unbounded. Bounded in practice (<10 agents);
add a comment for the future maintainer who may need to evict.

Notes 1 and 4 left unchanged: missingkey=zero is the friendlier choice
(produces a visible '<no value>' in the inbox rather than a silent
500); fsnotify double-fire is already handled by os.ErrNotExist on
second-read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:47:40 -04:00
bob
c22558c67a 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.
2026-05-06 17:44:57 -03:00
bob-boat
4ff8c3f78d Collector milestone 6: packaging — install.sh, systemd unit, docs
systemd/agent-watcher.service: --user unit with on-failure restart,
ProtectSystem=strict, ProtectHome=read-write, NoNewPrivileges=yes,
PrivateTmp=yes. JSON logs to journald. Survives reboot via
'loginctl enable-linger'.

examples/collector.yaml: working starter config for both sources with
inline comments, per-route examples, and the spec §3.1.2 schema for
drop files.

install.sh: idempotent installer following the agent-ping pattern.
Builds the binary, installs it + the unit, drops the example config if
absent, reloads systemd, enables, and (unless --no-start) starts the
service. Adds drop-folder lifecycle artifacts (*.tmp, .dead-letter/)
to workspace .stignore so they don't replicate during processing.
Skips Syncthing-related steps gracefully when ~/Nyx/workspace is not
present.

INSTALL.md: prerequisites, install, configure, verify (drop-file +
webhook end-to-end probes), survive-logout, uninstall, troubleshooting
table.

README.md: rewritten to reflect actual status — v0 working with 43
tests, packaging ready, Layer 2 in progress on Bob's side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:26:16 -04:00
bob-boat
e7d4ea036a Collector milestone 5: end-to-end integration tests
cmd/agent-watcher/main_test.go builds the real binary in TestMain, then
launches it twice with temp configs to exercise the full path:

TestEndToEnd_BothSourcesEmitToInbox
  - drops a *.json file via tmp+rename (mirrors Syncthing semantics)
  - POSTs a webhook with template variables ({{ .repo }}, {{ .actor }})
  - POSTs a urgent alert with empty body and fixed-string template
  - asserts 3 JSONL lines land in bob.inbox with exact shape
  - confirms each event's source field tracks origin
    ("drop-folder:drop1.json", "webhook:/forgejo/push")
  - hits /health and verifies emitted=2 (one webhook didn't 200, that
    counter only counts successful emits)

TestEndToEnd_GracefulShutdown
  - SIGTERM after listener up
  - asserts process exits within 3s

Total: 43 tests across 5 packages, all passing. Real binary verified
end-to-end on Linux/amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:23:34 -04:00
bob-boat
2183850c03 Collector milestone 4: config loader + main wiring (binary builds)
internal/config loads ~/.config/agent-watcher/collector.yaml with strict
validation (KnownFields=true, so typos fail loud), applies sensible
defaults, and expands ~/ in path fields. Either source can be omitted
but at least one must be configured.

cmd/agent-watcher is the entry point: load config, build inbox.Writer,
build configured sources, run them concurrently with a shared Emit
closure, wait for SIGINT/SIGTERM, shut down. Logs to stderr — text by
default, JSON via --json-log for journald structured fields per spec
§3.4.

SIGHUP reload is a v2 item; for now restart the systemd unit to pick
up config changes.

10 config tests passing — full happy path, defaults applied, ~/
expansion, and a table of 9 invalid configs that must all reject
(missing agent, no sources, empty webhook routes, route missing
recipient/type/template, route path without leading /, unknown
top-level field, negative poll seconds).

Binary builds clean: 10.8M single static binary on Linux/amd64.
go.mod stays at Go 1.22 to match VPS toolchain.

Total: 41 tests across 4 packages, all passing. Build clean. go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:22:03 -04:00
bob-boat
ba6db7c82f Collector milestone 3: HTTP webhook source + /health
internal/source/webhook routes inbound POSTs to inbox events via a
configured table. Each route specifies recipient, type, priority, and
a Go text/template payload renderer that consumes the request body
decoded as JSON.

v1 binds loopback only — New() rejects non-loopback addresses at
construction. Caddy + bearer-token reverse-proxy is the v2 upgrade
path per spec §4.

Behavior:
- POST + matched route + valid JSON body → render template, emit, 202
- Missing route → 404
- Wrong method → 405
- Bad JSON → 400
- Template render failure → 500
- Emit failure → 500 (caller responsible for retry; HTTP source has no
  durable staging)
- Empty body → empty data map for template (lets fixed-string templates
  work without sending {})
- 1 MiB request body cap

GET /health returns JSON Stats{received, emitted, errors, uptime_sec}
on the same listener for journalctl correlation per spec §3.5.

10 tests passing — non-loopback rejection, bad type/template
rejection, route+template happy path, priority defaulting, empty body,
404/400/405/500, health endpoint counters.

31 tests across the three internal packages, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:17:01 -04:00
bob-boat
f9d81471c4 Collector milestone 2: source interface + drop-folder source
source.Source is the contract every Collector input implements: Name +
Run(ctx, emit). Sources don't own state — they convert external events
into emit calls. Dispatcher routes.

internal/source/dropfolder: watches ~/Nyx/workspace/incoming/ for
*.json drop files. fsnotify-driven with periodic poll fallback (default
30s safety net for missed events). Each file:

1. Parsed against the spec §3.1.2 schema with DisallowUnknownFields.
2. Valid → emitted, then file deleted.
3. Invalid (missing fields, bad type/priority, unknown fields, garbage)
   → moved to .dead-letter/ with a sidecar .reason file for forensics.
4. Emit failure → file retained in place for retry (transient errors
   shouldn't be permanent dead-letters).

Also: initial-scan on Run() drains files that landed before the watcher
attached, catching up after a Collector restart.

14 tests in the package — schema validation table for all error cases,
initial-scan, live inotify drop, post-emit delete, dead-letter +
sidecar, emit-failure retention. Plus the 7 inbox tests still passing.

Pinned fsnotify v1.7.0 (Go 1.22 compatible; 1.10.x demanded toolchain
1.23 which isn't in apt yet). go.mod stays at 1.22 to match VPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:14:03 -04:00
bob-boat
50e8ece83d Collector milestone 1: inbox writer + tests
Layer 1 keystone. The internal/inbox package writes ping-shaped JSONL
events to recipient inbox files in a format bit-identical to the
agent-ping CLI's output, so the existing UserPromptSubmit hook and the
future MCP Watcher cannot tell whether a line came from `ping` or the
Collector.

- O_APPEND opens for atomic line writes (POSIX guarantees writes <=
  PIPE_BUF, our lines are well under).
- Per-recipient sync.Mutex bounds contention; multiple goroutines
  writing to one inbox stay correctly serialized.
- 7 tests passing: shape, ID/TS preservation, omitempty for optional
  fields, key-set + compactness match against ping CLI's separators=
  (",",":") output, 100-goroutine concurrent-write torn-line check,
  bad-input rejection, empty-dir rejection.

go.mod at git.botbought.ai/foreman/agent-watcher; module name matches
the public Forgejo path so eventual consumers can `go get` it.

Next milestones:
- Source plugin interface
- Drop folder source (inotify, via fsnotify)
- HTTP webhook source
- Config loader (YAML)
- main.go wiring
- systemd unit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:09:56 -04:00
bob-boat
3eda72df28 Add Channels reference docs snapshot for Layer 2 implementation
Snapshot of official Anthropic Channels docs (channels.md, channels-reference.md,
routines summary) fetched 2026-05-06 from code.claude.com. Bob needs these to
implement the MCP Watcher in Layer 2; the channels-reference.md is the primary
implementation reference.

docs/channels/README.md cross-references the snapshot back to spec §4 and notes
the key facts confirmed: capability key, notification method, lifecycle, the
research-preview --dangerously-load-development-channels caveat, and version
minimums (v2.1.80 for channels, v2.1.81 for permission relay which we are not
using in v1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:04:32 -04:00
bob-boat
c91f76039d Initial: README, spec/agent-watcher.md v1 (signed off by Bob 2026-05-06), .gitignore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:36:45 -04:00