Commit Graph

17 Commits

Author SHA1 Message Date
Hongming Wang
96a753dcf1
Merge pull request #21 from Molecule-AI/test/bun-test-bootstrap-extract-text
test(extractText): bootstrap bun:test + pin v0/v1 part discriminator
2026-04-30 20:15:57 -07:00
Hongming Wang
68d8ae981f test(extractText): bootstrap bun:test + pin v0/v1 part discriminator
Extracts the extractText helper + ActivityEntry type into their own
module so unit tests can import them without dragging server.ts's
top-level boot side-effects (cursor load, MCP transport connect, poll
loop) into the test runner. server.ts re-imports both — the wire
behavior is unchanged.

Tests cover the v0/v1 part-discriminator regression that landed on
2026-04-30 (every canvas peer message returning act.summary because
parts had `kind` instead of `type`):
- v1 `kind: text` parts (current production shape)
- v0 `type: text` back-compat
- multi-part text join, ignore non-text parts
- body-shape priority: params.message.parts > params.parts > body.parts
- empty-text-part recovery: skips empty candidate, tries next
- summary fallback when no shape matches
- `(empty A2A message)` fallback when summary is null

Adds:
- extract-text.ts (helper + type, no side-effects)
- extract-text.test.ts (9 tests)
- .github/workflows/test.yml (bun test on push/PR)
- "test": "bun test" script in package.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:15:31 -07:00
Hongming Wang
69a21a0ea9
Merge pull request #20 from Molecule-AI/fix/extract-text-kind-discriminator
fix(extractText): accept both kind and type part discriminators
2026-04-30 18:28:56 -07:00
Hongming Wang
7402011d80 fix(extractText): accept both kind and type part discriminators
a2a-sdk v0 used `type`, v1 (current production) uses `kind`. The real
platform sends `kind === 'text'` for text parts, so the v0-only filter
silently dropped every part on every inbound A2A message — the user
saw act.summary instead of the actual message body.

Same v0/v1 part-shape bug that was fixed in workspace/inbox.py
(molecule-core PR #2415); this is the channel-bridge port.

Reproduced live on hongmingwang tenant 2026-04-30: canvas peer
messages were arriving in /workspaces/:id/activity but Claude saw
only the summary line, not the actual message text. Same fix as
the universal-MCP path: filter on `kind === 'text' || type === 'text'`.

No tests added — channel-bridge repo has no test runner configured.
The equivalent fix in inbox.py has full v0/v1 part-shape tests
(test_inbox.py: test_extract_text_accepts_kind_text_v1,
test_extract_text_accepts_type_text_v0_compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:28:31 -07:00
Hongming Wang
40fa2d3810
Merge pull request #19 from Molecule-AI/feat/v0.3-canvas-user-reply-and-peer-discovery
feat(v0.3): mirror universal-MCP surface for external workspaces
2026-04-30 15:05:52 -07:00
Hongming Wang
bcc7db5c86 feat(v0.3): mirror universal-MCP surface for external workspaces
Brings the channel's tool surface up to parity with the in-container
universal MCP (workspace/platform_tools/registry.py). External agents
driven through this channel now get the same 9 tools that an in-container
agent gets — same names, same input shapes, same semantics — so a hermes
or codex session bridged via this channel can do anything a containerized
claude-code can.

Tools:
- reply_to_workspace — smart-routed (canvas_user → /notify, peer_agent → /a2a).
  Was peer-only; now handles canvas chat replies too. Works for the most
  common case (user types in My Chat, agent replies in My Chat) which the
  v0.2 channel silently dropped.
- delegate_task / delegate_task_async / check_task_status — proactive A2A.
  Previously only inbound peer messages could trigger an outbound a2a;
  the agent could not initiate.
- list_peers — peer discovery. Backed by GET /registry/:id/peers.
- get_workspace_info — self-introspection. Needed for memory scope checks
  (only tier-0 roots can write GLOBAL).
- send_message_to_user — canvas push with attachments. Multipart upload via
  /chat/uploads then /notify, mirrors the universal tool's two-phase shape.
- commit_memory / recall_memory — persistent memory across sessions, with
  LOCAL/TEAM/GLOBAL scopes. Platform enforces RBAC + scope.

Inbound delivery:
- Drop seed-then-skip on first run. The previous policy assumed events
  before session start were "out of context"; in practice operators
  restart Claude Code mid-conversation and EXPECT the queued messages.
  Cold start now delivers everything in POLL_WINDOW_SECS and advances
  the cursor past it.
- emitNotification adds meta.kind ('canvas_user' | 'peer_agent') so
  Claude can pick the right reply form without re-parsing the row.

Channel-specific:
- New tools take an optional _as_workspace param to disambiguate when
  watching multiple workspaces. Underscore-prefixed so it can't collide
  with the universal contract (which is bound to a single workspace).
2026-04-30 14:57:46 -07:00
Hongming Wang
58beccc06c
Merge pull request #18 from Molecule-AI/fix/v0.2.2-probe-order-and-pid-cleanup
fix: probe ordering + PID file cleanup on exit(2) (v0.2.2)
2026-04-30 00:06:21 -07:00
Hongming Wang
fef6a6210e fix: probe ordering + PID file cleanup on exit(2) (v0.2.2)
Independent five-axis review of v0.2.1 surfaced three issues. All
three only manifest after the upgrade-or-die path actually fires,
which is rare in normal operation but exactly the failure mode the
probe was added for — so quiet correctness matters.

1. **CRITICAL: process.exit(2) orphans PID file → cross-process-kill
   hazard.** v0.2.1 added a fatal exit on too_old probe response, but
   left the PID file pointing at this (about-to-die) process. Next
   launch reads PID_FILE, calls `process.kill(stale, 'SIGTERM')` —
   the dead PID is now likely owned by an unrelated process, so the
   plugin SIGTERMs whatever happens to be there. This is exactly the
   bug the singleton lock dance was designed to prevent. Fix: install
   a `process.on('exit')` listener that unlinks PID_FILE iff this
   process still owns it (handles all exit paths: clean shutdown,
   probe failure, unhandledException, etc.). Listener uses sync I/O
   (only path 'exit' permits) and is no-op-safe if another process
   has already taken ownership of the file.

2. **Probe ran AFTER mcp.connect(transport).** Claude Code had
   already finished the MCP initialize handshake when the probe
   detected too_old and exited; from Claude's perspective this looked
   like "MCP server crashed mid-session" — the carefully-written
   stderr explanation isn't surfaced. Probe must run BEFORE
   mcp.connect so the failure mode is "server failed to start" with
   stderr visible to the user.

3. **Probe ran AFTER registerAsPoll().** If the platform predates
   delivery_mode=poll AND since_id (consistent — both shipped under
   #2339), registerAsPoll() may have already mutated the workspace's
   delivery_mode on the platform. Then the probe fails and we exit,
   leaving the workspace in a broken half-configured state. Probe
   must run BEFORE registerAsPoll for the same reason it must run
   before mcp.connect — fail without side effects.

Reordered boot sequence:
  loadCursors → probeCursorSupport (parallel via allSettled) →
  mcp.connect → registerAsPoll → poll loop.

Also parallelized the probe with Promise.allSettled — sequentially
it was up to N × 15s on a hung platform, which adds up for
multi-workspace channels.

README updated to document that 401/403/404/5xx from the probe are
treated as inconclusive (orthogonal to cursor support — usually
auth or transient) and the plugin continues; this avoids users
debugging a phantom probe failure when the real issue is their
token.

Bumps to 0.2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:05:47 -07:00
Hongming Wang
e7a2dce756
Merge pull request #17 from Molecule-AI/fix/v0.2.1-probe-cursor-support
fix: probe cursor support at startup, fail loudly on pre-#2354 platform (v0.2.1)
2026-04-29 23:36:42 -07:00
Hongming Wang
8da0c47955 feat: probe cursor support at startup (v0.2.1)
Plugin v0.2 polls /activity?since_id=<cursor>. Older platforms (pre
molecule-core#2354) silently ignore the param and return whatever the
default time window covers — every poll then re-delivers the same
activities to Claude as fresh turns. That's a worse failure mode than
any v0.1 bug.

Add a startup probe: send since_id=00000000-…-000000000000 (the all-zero
UUID, which gen_random_uuid() can never produce per RFC 4122 §4.4 so a
valid 410 is unambiguous). #2354+ answers 410 Gone for any unknown
cursor; pre-#2354 answers 200 OK. On 200 the plugin exits with code 2
and a clear "upgrade your platform or downgrade the plugin" message.

401/403/404/5xx are inconclusive (orthogonal to cursor support) — log a
warning, let the normal poll loop surface the real failure.

Bumps package.json + the MCP server-name version string to 0.2.1 (the
0.1.0 in the Server constructor was already stale, fixed at the same
time). README's compat section documents the probe + exit code so users
debugging an immediate exit-code-2 know what's going on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:34:22 -07:00
Hongming Wang
d862aa6c2c
Merge pull request #16 from Molecule-AI/feat/v0.2-cursor-poll-mode
feat: v0.2 — cursor-based polling + auto-register as poll-mode
2026-04-29 22:35:58 -07:00
Hongming Wang
18657ebf59 feat: v0.2 — cursor-based polling + auto-register as poll-mode
Switches from the v0.1 since_secs+seenIds time-window scheme to a
Telegram-shaped since_id cursor (Molecule-AI/molecule-core#2354), and
adds a startup self-register that sets the workspace's delivery_mode
to poll (Molecule-AI/molecule-core#2353) so the platform's a2a_proxy
short-circuits inbound A2A into activity_logs without requiring a
public URL.

What changed:
- Cursor persistence: ~/.claude/channels/molecule/cursor.json, atomic
  temp+rename writes, chmod 600. Survives restarts; no replay window,
  no in-memory dedup growth.
- pollWorkspace: first run seeds cursor from most-recent without
  delivering (events that predate the session are out of context).
  Steady state uses ?since_id=<cursor>; ASC-ordered rows strictly
  after cursor get delivered in recorded order. 410 → drop cursor +
  re-seed next tick.
- registerAsPoll: POST /registry/register {delivery_mode:"poll",
  agent_card:{name,description}} for each watched workspace on boot.
  Idempotent (upsert). Skipped when MOLECULE_AUTO_REGISTER_POLL=false
  for users who configured the workspace another way.
- New env vars: MOLECULE_AGENT_NAME, MOLECULE_AGENT_DESC,
  MOLECULE_AUTO_REGISTER_POLL.
- README: cursor section + compat bumped to require molecule-core
  PRs #2348/#2353/#2354 (issue #2339).

Compat: requires platform to ship #2339. Plugin works against older
platforms only if you set MOLECULE_AUTO_REGISTER_POLL=false and the
workspace already has delivery_mode=poll set another way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:34:42 -07:00
Hongming Wang
c7b9e5500c
Merge pull request #15 from Molecule-AI/fix/origin-header-for-saas-waf
fix: send Origin header so SaaS edge WAF accepts /workspaces/* fetches
2026-04-29 21:08:57 -07:00
Hongming Wang
2f08478edb fix: send Origin header so SaaS edge WAF accepts /workspaces/* fetches
Production tenants front the workspace-server with a Cloudflare WAF that
enforces same-origin on /workspaces/* paths. Without an Origin header the
WAF silently re-routes the request to the canvas Next.js (which has no
/workspaces page), so polls returned empty 404s and replies failed with
an opaque error.

Browsers set Origin automatically for cross-origin POSTs; Node/Bun fetch
does not (it's a browser-only concern). Both fetch sites in this plugin
hit /workspaces/* with a workspace bearer that's only valid against
PLATFORM_URL anyway, so we set Origin: PLATFORM_URL explicitly — no risk
of leaking the bearer to a different origin.

Verified against hongmingwang.moleculesai.app:
  - Pre-fix: GET /workspaces/:id/activity → empty 404 (WAF re-route)
  - Post-fix: GET /workspaces/:id/activity → 200 [] (correct)

The reply path gets the same fix; e2e verification of replies is blocked
upstream (the platform's outbound forward to a registered URL needs the
agent reachable from the platform's network), unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:08:04 -07:00
Hongming Wang
f3402e48ff
Merge pull request #1 from Molecule-AI/fix/v0.1-jsonrpc-envelope-handling
fix: walk JSON-RPC envelope in extractText + send proper envelope on reply
2026-04-29 12:23:21 -07:00
Hongming Wang
8684a47989 fix: walk JSON-RPC envelope in extractText + send proper envelope on reply
Two-workspace E2E test (2026-04-29) against a real molecule-core platform
caught two mirror-image bugs in v0.1's A2A wire-shape handling:

1. **extractText looked at the wrong path.** The plugin assumed
   `request_body.parts[].text` but real activity_log rows from the
   platform's a2a_proxy use the proper JSON-RPC shape:
   `request_body.params.message.parts[].text`. With shorthand bodies the
   platform was stripping params, so the plugin was falling back to
   `summary` (auto-generated " → workspace-name" string) instead of the
   actual peer message.

2. **reply_to_workspace was sending shorthand `{parts:[...]}`.** The
   platform accepted the request but the a2a_proxy stripped params before
   forwarding to the peer's URL — peer received an envelope with
   `params:null`, no message text. Wrapping in proper JSON-RPC 2.0
   (`{jsonrpc, id, method:"message/send", params:{message:{messageId, parts}}}`)
   preserves the message all the way through.

Verified by E2E: real A2A from workspace B → A surfaces correctly in the
notification; reply from A via the plugin lands at B with intact text.

The extractText helper now walks 3 shapes in priority order so backward-
compat with shorthand-sending callers (canvas-direct-sends) is preserved.

Known limitation flagged but NOT fixed here: a2a_proxy doesn't populate
activity_log.source_id from the X-Source-Workspace-Id request header, so
notifications surface with `peer_id: ""`. That's a platform-side fix
(workspace-server/internal/handlers/a2a_proxy_helpers.go logA2ASuccess);
will file separately in molecule-core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:11:12 -07:00
Hongming Wang
d07363cbe5 feat: initial scaffold — Molecule channel plugin for Claude Code
Bridges Molecule A2A traffic into a Claude Code session via MCP. Inbound
A2A messages from watched workspaces surface as conversation turns
(notifications/claude/channel); replies route back through the existing
POST /workspaces/:id/a2a endpoint via the reply_to_workspace MCP tool.

Architecture:
- Polling-based inbound (uses /activity?since_secs= shipped in molecule-core
  PR #2300). Works through every NAT/firewall, no tunnel required —
  optimized for laptop-launched Claude Code sessions vs the existing
  push-based external-agent flow that needs ngrok.
- Per-workspace bearer auth (MOLECULE_WORKSPACE_TOKENS, comma-separated to
  match MOLECULE_WORKSPACE_IDS). Same token covers /activity (read) and
  /a2a (write).
- Singleton lock at ~/.claude/channels/molecule/bot.pid prevents two
  channel servers racing the dedup state.
- Dedup by activity.id; 30s overlap window over a 5s poll interval
  protects against missed ticks (laptop sleep, transient network blips).

v0.1 ships:
- .claude-plugin/plugin.json, .mcp.json, package.json, LICENSE (Apache-2.0)
- server.ts: MCP server with notification emission + reply_to_workspace tool
- README: install + .env config + architecture notes + v0.2 roadmap

v0.1 explicit non-goals (tracked in README):
- No push-mode inbound (requires tunnel; deferred to v0.2)
- No pairing flow (manual .env tokens; canvas pairing in v0.2)
- No file-attachment download (URLs surface in meta; host fetches on-demand)
- No outbound channel-init (only replies; start_workspace_chat in v0.2)

Mirrors the architecture of @claude-plugins-official/telegram v0.0.6
(MCP notification contract: notifications/claude/channel with
{content, meta}) so the host's existing channel-handling logic works
without custom adapters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:30:23 -07:00