Assorted conhost fixes: MCPL policy, stderr forwarding, typing metadata, workspace watcher#26
Conversation
…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>
|
Follow-up commit 🟡 stopTyping asymmetry — single-channel branch now dispatches the 🟡 Mid-typing metadata update latency — 🟡 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: |
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>
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 policy—enabledTools/disabledToolsonMcplServerConfig, bare names with*wildcards. Filtered both attools/listtime 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 onserverId, 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/typing—sendChannelsTyping/ChannelRegistry.startTypingaccept an optionalRecord<string, unknown>that travels with the notification. Intentionally untyped per-server (Zulip readstopic, 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/typing—op: 'start' | 'stop'added;ChannelRegistry.stopTypingdispatches 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 achannelsgetter (null when no MCPL servers) and re-exportedChannelRegistryfrom the package entry so consumers can type against it.Workspace
feat(workspace): public resolveAbsolutePath for peer modules— turns amount/sub/file.mdpath into an absolute FS path without peer modules reaching into privateparsePathstate. 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 anerrorhandler. Post-failure state is indistinguishable from watching an empty dir. Three mitigations, all canonical chokidar usage:mkdir -pthe watch path before attach for read-write mounts (read-only left alone).WatcherLifecyclecallbacks (onReady,onError) forwarded asworkspace:watcher-errorevents.watcherReadyAt+watcherError, persisted via snapshot. NullwatcherReadyAton a live session's mount = watcher never attached; set timestamp with empty tree = attached-but-empty.watcherReadyAtis intentionally not restored across restarts — stale timestamp would mask a fresh attach failure.@parcel/watcher.Test plan
npx tsc --noEmitcleannode --import tsx --test test/tool-policy.test.tsnode --import tsx --test test/workspace-watcher.test.ts🤖 Generated with Claude Code