Skip to content

Assorted conhost fixes: MCPL policy, stderr forwarding, typing metadata, workspace watcher#26

Merged
Anarchid merged 8 commits into
anima-research:mainfrom
Anarchid:assorted-conhost-fixes
Apr 23, 2026
Merged

Assorted conhost fixes: MCPL policy, stderr forwarding, typing metadata, workspace watcher#26
Anarchid merged 8 commits into
anima-research:mainfrom
Anarchid:assorted-conhost-fixes

Conversation

@Anarchid
Copy link
Copy Markdown
Collaborator

Summary

Batch of small conhost-driven improvements discovered while integrating AF into connectome-host. Each commit stands alone; ordered by topic rather than dependency.

MCPL

  • feat(mcpl): per-server tool allow/deny policyenabledTools/disabledTools on McplServerConfig, bare names with * wildcards. Filtered both at tools/list time and at dispatch time — schema omission alone doesn't stop a model from imitating a denied call it's already seen in history. Deny wins on overlap.
  • feat(mcpl): forward subprocess stderr as mcpl:server-stderr trace events — child stderr was previously silently dropped. Now every line becomes a trace event keyed on serverId, with pre-handshake lines buffered and drained once the instance exists; rebindable for the reconnect probe → persistent swap.
  • feat(mcpl): thread opaque routing metadata through channels/typingsendChannelsTyping / ChannelRegistry.startTyping accept an optional Record<string, unknown> that travels with the notification. Intentionally untyped per-server (Zulip reads topic, others pick their own keys). Registry caches per-channel metadata so the 7s refresh stays on the same thread.
  • feat(mcpl): explicit stop op on channels/typingop: 'start' | 'stop' added; ChannelRegistry.stopTyping dispatches one final stop before clearing the interval, so servers that support explicit stop (Zulip) clear immediately instead of waiting for auto-expire. Metadata carries on the stop too.

Framework

  • feat(framework): expose ChannelRegistry on AgentFramework — was a private field; modules that need channel-level ops (typing, default publish channel) had no way in. Added a channels getter (null when no MCPL servers) and re-exported ChannelRegistry from the package entry so consumers can type against it.

Workspace

  • feat(workspace): public resolveAbsolutePath for peer modules — turns a mount/sub/file.md path into an absolute FS path without peer modules reaching into private parsePath state. Honors the same path-traversal guard.
  • fix(workspace): chokidar-correct-use mitigations for silent watcher failures — reproducibly on Linux/WSL, chokidar 4's fs.watch backend silently drops events when the watched path (or any parent) doesn't exist at subscribe time and surfaces no error without an error handler. Post-failure state is indistinguishable from watching an empty dir. Three mitigations, all canonical chokidar usage:
    • mkdir -p the watch path before attach for read-write mounts (read-only left alone).
    • WatcherLifecycle callbacks (onReady, onError) forwarded as workspace:watcher-error events.
    • Mount state carries watcherReadyAt + watcherError, persisted via snapshot. Null watcherReadyAt on a live session's mount = watcher never attached; set timestamp with empty tree = attached-but-empty. watcherReadyAt is intentionally not restored across restarts — stale timestamp would mask a fresh attach failure.
    • Does not address inode-replace (dir rm'd + recreated post-subscribe); that needs re-attach logic / @parcel/watcher.

Test plan

  • npx tsc --noEmit clean
  • node --import tsx --test test/tool-policy.test.ts
  • node --import tsx --test test/workspace-watcher.test.ts
  • Smoke test in connectome-host: zulip typing indicators clear promptly, disabled tool denied both at list and dispatch, mcpl server stderr surfaces as traces

🤖 Generated with Claude Code

Anarchid and others added 8 commits April 23, 2026 11:55
…ailures

Reproducibly on Linux/WSL (and consistent with the observed macOS session):
chokidar 4's fs.watch backend silently drops events when the watched path —
or any of its parent directories — doesn't exist at subscribe time, and
surfaces no error unless an 'error' handler is attached. The recorded
state after this failure is indistinguishable from watching an empty
directory.

Three mitigations, all canonical chokidar usage:

- MountWatcher.start() now mkdir -p's the watch path for read-write
  mounts before attach, covering the missing-parent topology.
  Read-only mounts are left alone — creating directories a user asked
  us only to read would be surprising.
- WatcherLifecycle callbacks (onReady, onError) give the workspace
  module observability into the watcher's attach state. Errors are
  forwarded as workspace:watcher-error events.
- Mount state now carries watcherReadyAt + watcherError, persisted via
  snapshot. A null watcherReadyAt on a live session's mount unambiguously
  means the watcher never finished attaching; a set timestamp with an
  empty tree means attached-but-empty. watcherReadyAt is intentionally
  NOT restored across restarts — a stale timestamp would mask a fresh
  attach failure.

Does not address the inode-replace failure mode (dir rm'd + recreated
post-subscribe); chokidar can't recover from that without re-attach
logic, and @parcel/watcher is the better fix when we want to tackle it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MCPL subprocess stderr was previously silently dropped, which makes
diagnosing misbehaving servers a matter of guesswork for anything
downstream of the connection. Now every line of child stderr is
emitted as an mcpl:server-stderr trace event (one trace per line),
so consumers — conhost, log sinks, TUI badges — can persist and
surface them.

Implementation:
- McplServerConnection.connect() now wires stderr forwarding on the
  spawned child. Lines arriving before the instance exists are buffered
  and drained once the instance is constructed, so pre-handshake stderr
  isn't lost.
- The line handler is swappable via rebindStderr; the reconnect path
  uses it to re-target forwarding from the throwaway probe connection
  to the persistent one.
- AgentFramework.wireMcplEvents() subscribes to the 'stderr' event and
  emits mcpl:server-stderr traces keyed on serverId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modules that need channel-level operations (typing indicators, default
publish channel selection, etc.) had no way to reach the channel
registry — it was a private field. Expose it via a `channels` getter
that returns null when no MCPL servers are configured, and re-export
the ChannelRegistry class from the package entry point so consumers
can type against it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sendChannelsTyping / ChannelRegistry.startTyping accept an optional
Record<string, unknown> that travels with the notification to the
server. Intentionally not typed per-server: each server picks the keys
it cares about (e.g. Zulip reads `topic`). The registry caches
per-channel metadata so the 7s refresh keeps the routing hint stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sendChannelsTyping / sendTypingFn gain an op: 'start' | 'stop'
parameter, defaulting to 'start'. ChannelRegistry.stopTyping dispatches
one final 'stop' notification before clearing the interval, so servers
that support an explicit stop (Zulip) clear the indicator immediately
instead of waiting for server-side auto-expire. Metadata is carried on
the stop too, so the clear targets the same topic/thread as the start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds WorkspaceModule.resolveAbsolutePath(mountPrefixedPath) so peer
modules (initially ActivityModule for origin-hint peeking) can turn
"mount/sub/file.md" into its absolute filesystem path without reaching
into private parsePath state. Honors the same path-traversal guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds enabledTools/disabledTools to McplServerConfig — bare tool names
with `*` substring wildcards. Filtered both at tool-list time (model
never sees the tool) and at dispatch time (call rejected with a
tool-result error). The dual gate is deliberate: schema omission alone
doesn't stop a model from imitating a denied call it sees in its own
prior message history.

Deny wins over allow on overlap. Regex metacharacters in patterns are
escaped, so only `*` is special.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review findings on anima-research#26:

- stopTyping(channelId) no longer dispatches a 'stop' notification when
  there was no active interval. Matches the global-clear branch's
  semantics and keeps defensive stopTyping(ch) calls from spamming
  stops at a server that never saw a start.
- startTyping(channelId, metadata) on an already-typing channel now
  dispatches an immediate refresh when the new metadata differs from
  the cached one, instead of waiting up to TYPING_INTERVAL_MS for the
  next tick. This is precisely the Zulip topic-switch case the op:
  'stop' commit was meant to cover; the fast-path refresh uses the
  same routing-hint contract without the stop/start flap.

Also adds a comment on the reconnect stderr rebind acknowledging that
it inherits the same "transfer internals, leak listeners on the dead
instance" pattern as the surrounding message-routing and lifecycle
rewiring — a TODO for whoever cleans up that reconnect shape.

Tests: adds test/channel-registry-typing.test.ts (6 cases) covering
both symmetry and the metadata-change fast-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Anarchid
Copy link
Copy Markdown
Collaborator Author

Follow-up commit 32cb10c addresses internal review findings:

🟡 stopTyping asymmetry — single-channel branch now dispatches the stop notification only when an interval was actually active, matching the global-clear branch. Defensive stopTyping(ch) calls no longer spam stop notifications at servers that never saw a start.

🟡 Mid-typing metadata update latencystartTyping(ch, newMetadata) on an already-typing channel now dispatches an immediate refresh when the metadata differs from the cached one, instead of waiting up to 7s for the next tick. This is exactly the Zulip topic-switch case the op:'stop' commit was meant to cover; the fast-path refresh reuses the same routing-hint contract without stop/start flap.

🟡 Reconnect stderr rebind — added a code comment acknowledging it inherits the same "transfer internals, leak listeners on the dead instance" pattern as the message-routing/lifecycle rewiring. Not reworked — worth knowing for whoever cleans that reconnect shape up.

New test: test/channel-registry-typing.test.ts — 6 cases covering both symmetry and the metadata-change fast-path. Full suite green (88 tests across 7 files).

@Anarchid Anarchid merged commit bf46a4e into anima-research:main Apr 23, 2026
Anarchid added a commit that referenced this pull request Apr 23, 2026
Release bundles the assorted-conhost-fixes PR (#26):
- feat(mcpl): per-server tool allow/deny policy (enabledTools/disabledTools
  with `*` wildcards; dual-gate at list + dispatch)
- feat(mcpl): forward subprocess stderr as mcpl:server-stderr trace events
- feat(mcpl): thread opaque routing metadata through channels/typing with
  explicit op: 'start' | 'stop' and immediate refresh on mid-stream
  metadata change
- feat(framework): expose ChannelRegistry on AgentFramework + re-export
- feat(workspace): public resolveAbsolutePath for peer modules
- fix(workspace): chokidar silent-failure mitigations (mkdir-p watch path,
  WatcherLifecycle callbacks, persisted watcherReadyAt/watcherError)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant