Cross-compile scalagent to JVM (Scala.js + JVM artifacts)#44
Conversation
Splits `agent` Mill module into `agent.js` + `agent.jvm`. Moves 17 hard-JS files (Claude SDK wrapper, Codex SDK, @a2a-js/sdk facades, McpServer, AsyncIteratorOps, QueryStream, ZodFacade, ToolFiles) into src-js/. `mill agent.jvm.compile` still fails (232 errors) because 30+ shared-src files still use scala.scalajs.js (UndefOr / Dynamic / Function / Promise). Exploration of the depth of contamination is the next step before committing to the refactor scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1b checkpoint. Both targets compile cleanly:
- `mill agent.js.compile` — Scala.js (Bun) artifact, unchanged behaviour
- `mill agent.jvm.compile` — new JVM artifact, 28 shared sources
Structural changes:
- Source layout: src/{shared,js,jvm}/; test/{shared,js,jvm}/.
- build.mill: agent.js (BunScalaJSModule) + agent.jvm (ScalaModule).
Both publish artifactName "scalagent" with cross-build suffixes.
- A2AServer.scala split: A2AServerTypes.scala in shared carries the
pure traits (A2AServer, A2AEventPublisher, A2AEventStore,
A2AReplayProvider, PushNotificationUrlPolicy, A2ATaskStore +
InMemoryTaskStoreImpl). The 1521-line Bun runtime impl stays in
src/js/a2a/A2AServer.scala as A2AServerLiveImpl + A2AServerLive
factory + JS-only Config.
- A2ATypes: java.util.UUID instead of @jsglobal("crypto"). Polyfilled
on Scala.js, native on JVM.
- PushNotificationUrlPolicy.externalOnly: java.net.URI instead of
js.Dynamic.newInstance(URL). Cross-build.
- A2AEventIds extracted to src/shared/a2a/ (pure Scala).
Files in src/shared/ (cross-built, ~28 files):
- a2a/ pure protocol types (A2AMessage, A2ARequest, A2AResponse, AgentCard,
JsonRpc, ExecutionMode, A2AError, A2ATask, A2APushNotificationStore,
A2ATypes, A2AEventIds, A2AServerTypes)
- errors/AgentError
- json/ codecs
- types/
- config/{Model, AgentModel, Effort, OutputStyle, PermissionMode,
PositiveInt, PositiveDouble, ...} (the value types; the Claude-SDK
adapter configs went to JS)
Files moved to src/js/ (~107 files):
- All Claude SDK adapters (Claude, ClaudeAgent, Codex/*, Hook*, ToolDef,
AgentOptions, AgentDefinition, McpServerConfig, McpServer, etc.)
- Bun-runtime A2A files (A2AServer, A2AInternals, A2AClient, A2AV03,
WorkspaceStaging, facade/*, A2ATypes-old)
- Macros (ToolMacros, ParamConverter, SchemaGen)
- Claude-loop infrastructure (core/, messages/, streaming/, hooks/,
permissions/, session/, schema/, tools/, experimental/, interop/)
- DSL builder (BuilderConfig, AgentBuilder, TypedAgent, ToolSurface)
- Root package.scala (re-exports JS surfaces)
Out of scope: writing the JVM A2AServerLive (next task, ~300 LOC
zio-http). Publishing 0.8.0-SNAPSHOT. Tests cross-compile run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Minimum-viable JVM A2A server runtime for Phase 2b CMA agents. Mirrors
the JS A2AServerLive's request shape with a smaller surface — happy path
for synchronous message/send, plus tasks/get, tasks/list,
agent/getExtendedAgentCard, and a GET /.well-known/agent-card.json
REST endpoint.
src/jvm/com/tjclp/scalagent/a2a/A2AServerLive.scala:
- `A2AServerLive.Config` — JVM-only Config (no AgentOptions / no
invocationPreparer; otherwise field-by-field parallel to the JS Config)
- `A2AServerLive.{create, start, live}` — factory methods matching the
JS surface so callers don't reorganise
- `A2AServerLiveImpl` — private impl using:
- `zio.http.Server.serve(Routes(POST /, GET /.well-known/agent-card.json))`
- `CollectingPublisher` — in-memory `A2AEventPublisher` that buffers
events from `executionOverride` and folds them into the final task
(sync message/send semantics; SSE streaming deferred)
- `taskStore` (in-memory default or caller-supplied) for tasks/get,
tasks/list
Out of scope for this iteration:
- message/subscribe SSE streaming. The handler buffers all events and
returns the final task; subscribe will be added later by mirroring
the Bun A2AEventBus pattern.
- tasks/resubscribe, tasks/cancel
- Push notification config CRUD
- Per-task event store integration
These can be ported incrementally as JVM CMA agents need them. The
minimal surface is sufficient for Phase 2b echobot:
- Anthropic POSTs a message via the Go tjc CLI -> A2A POST /
- handler dispatches message/send to `executionOverride`
- executionOverride (in tjc-agents pkgs/sc/cma) talks CMA via
anthropic-sdk-java, streams events back through the publisher
- handler folds events into the final task and returns
`mill agent.jvm.compile` clean; `mill agent.js.compile` unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review: Cross-compile scalagent to JVM (PR #44)Nice work splitting Critical / Security
Bugs / Correctness
Test coverage
Minor / Style
What I liked
|
Summary
Adds a JVM target to scalagent's Mill build so it publishes both a Scala.js artifact (current behavior, unchanged for ndagent/finagent/memtest/example users) and a new JVM artifact suitable for the JVM/Scala binaries used by Claude Managed Agents (CMA)-backed agents.
This was driven by Phase 2b of the tjc-agents CMA refactor plan — a JVM-mode "echobot" agent that wires anthropic-sdk-java (JVM-only) and scalagent's A2A protocol surface into one binary. Live-fire validated end-to-end with echobot deployed to Modal: A2A request → CMA session → tool_use → webhook → Modal Sandbox → tool result → A2A reply.
What changed
Source layout:
src/→src/{shared,js,jvm}/. Existing flat tree restructured into a nested layout matching the tjc-agentspkgs/sc/agents/common/{js,jvm,shared}convention.src/shared/(~28 files): cross-built — A2A protocol types (A2AMessage, A2AResponse, AgentCard, JsonRpc, A2ATask, etc.),A2AServerTypes.scala(A2AServer trait, A2AEventPublisher, A2AEventStore, A2AReplayProvider, A2ATaskStore + InMemoryTaskStoreImpl, PushNotificationUrlPolicy refactored to java.net.URI), errors, json codecs, ID types, light config.src/js/(~107 files): Scala.js only — Claude Agent SDK adapters (Claude, ClaudeAgent, Codex, MCP server bindings, all 17toRaw: js.ObjectHook/Tool/Permission/Config adapters), Bun-runtime A2A files (A2AServer Bun impl + A2AServerLive factory, A2AInternals, A2AClient fetch-based, A2AV03, WorkspaceStaging), DSL builder, macros, streaming, hooks, permissions, session.src/jvm/(new): zio-httpA2AServerLive(~250 LOC, minimum-viable; happy-path SendMessage + GetTask + ListTasks + agent-card REST endpoint) +A2AServerLive.Config(drops the Claude-SDK-adapteragentOptions/invocationPreparerfields).Build:
agentbecomes a Module containingagent.js(BunScalaJSModule, current behavior) andagent.jvm(new ScalaModule). Both publish under artifactNamescalagentwith Maven cross-build suffixes (scalagent_sjs1_3vsscalagent_3).Refactors to make types cross-buildable without breaking JS callers:
A2ATypes:@JSGlobal(\"crypto\").randomUUID()→java.util.UUID.randomUUID()(polyfilled on Scala.js, native on JVM)PushNotificationUrlPolicy.externalOnly:js.Dynamic.newInstance(URL)(url)→java.net.URI(url)A2AServer.scala(1521L) split: trait + Config + auxiliary traits stay shared; private impls (A2AEventBus, A2ARuntimeRegistry, PushNotificationSender, EventStorePersister, ResultManager, A2ARequestHandler, A2AServerLiveImpl, BunServer) stay JS-only withA2AServerLivecompanion factory renamed fromA2AServer.Tests (new JVM-side):
A2AServerLiveSpec(2 tests): config equality, start/stop lifecycleA2ATaskStoreSpec(1 test): InMemory implementation round-tripPushNotificationUrlPolicySpec(2 tests): externalOnly rejects loopback/RFC-1918, allowAll passestest/{shared,js,jvm}/. Total: 137 JVM test cases pass; 190 JS test cases pass (no regressions).Mill version: bumps PUBLISH_VERSION default to
0.8.0-SNAPSHOTto reflect the new cross-build shape (existing artifact0.7.0-RC2was Scala.js only).Cross-repo handoff
This branch was developed against tjc-agents TJC-1110 which:
RuntimeVersions.Scalagentto0.8.0-SNAPSHOTRuntimeVersions.AnthropicJava = \"2.34.0\"pkgs/sc/cma/vendored module:CmaSession(trait + AnthropicOkHttpClient wrapper),CmaEvent(decoded Scala-friendly view ofBetaManagedAgentsStreamSessionEvents),CmaA2AExecution(factory producing theexecutionOverridefunction)pkgs/sc/agents/echobot/: minimal CMA-backed Scala agent; deployed to Modal as a JVM fat-jar via a hand-rolleddeploy.py(codegen foragentLoopMode = ManagedAgentsis Phase 2c)The 4 existing scalagent-consuming agents (ndagent, finagent, memtest, example) bump transparently — their
BunScalaJSModuleresolver picks the_sjs1_3artifact, unchanged behavior.Live-fire result
Phase 2b echobot fully deployed + tool-using flow exercised:
```
USER: What is the current working directory? Run pwd then exit.
CLAUDE → bash: pwd
SANDBOX: /__modal/volumes/vo-m6bSlsg21H2aBdrV2q6qSv
CLAUDE: The current working directory is /__modal/volumes/vo-m6bSlsg21H2aBdrV2q6qSv.
```
End-to-end (~16s warm round-trip) through Modal A2A web tier → scalagent.jvm A2AServerLive → CmaA2AExecution → anthropic-java → CMA → webhook → Modal Sandbox → tjc.managed_agents.worker → bash exec → Claude follow-up message.
Out of scope (Phase 2c+)
executionOverrideevents and returns a synchronous TaskResult;message/subscribereturns 405 today.taskId/contextIddon't yet map to a persistentsession_id.BetaManagedAgentsStreamSessionEventstaxonomy. CmaEvent decoder coversagent.message+session.status.idle+ a few; the rest fall intoUnhandled. Concrete agents will expand as needed.deploy.pyis hand-rolled; auto-codegen foragentLoopMode = ManagedAgentsagents lands in Phase 2c.Test plan
./mill agent.js.compileclean (no regression)./mill agent.jvm.compileclean./mill agent.js.testpasses 190 cases (no regression)./mill agent.jvm.testpasses 137 casesPUBLISH_VERSION=0.8.0-SNAPSHOT ./mill __.publishLocalproduces bothscalagent_sjs1_3andscalagent_3Maven artifacts in~/.ivy2/local/🤖 Generated with Claude Code