FineCode WM-ER Protocol¶
This document describes the communication protocol between the FineCode Workspace Manager (WM) and Extension Runners (ER). WM is the JSON-RPC client; each ER is a JSON-RPC server.
The WM-ER protocol uses JSON-RPC 2.0 with LSP-style wire framing. Lifecycle method
names (initialize, initialized, shutdown, exit) and text-document notification
names follow LSP conventions; all FineCode-specific commands use direct JSON-RPC
method names.
Transport¶
- JSON-RPC 2.0
- LSP-style framing (
Content-Length: N\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n{json}) over a loopback TCP socket. On startup the ER binds a free port on127.0.0.1and announces it on stdout asServing on ('127.0.0.1', <port>); the WM reads that line from the spawned process's stdout and connects to the advertised port. (A stdio transport also exists in the ER but is not the default path.) - WM spawns ER processes with:
python -m finecode_extension_runner.cli start --project-path=... --env-name=... --log-level=INFO--log-levelsets the global default log level for the ER process (default:INFO); fine-grained per-group overrides are delivered later viafinecodeRunner/updateConfig--debugenables a debugpy attach flow before WM connects (the debug port is announced on stdout asDebug session: 127.0.0.1:<port>)- All parameter object keys use camelCase.
Startup-failure diagnostics (requirement / edge case)¶
Until the ER publishes its port, there is no RPC channel, so er/logRecords
forwarding (see ER→WM notifications) cannot run — a runner that fails to start or exits
before announcing its port can forward nothing. Because the ER writes its own loguru
diagnostics to stdout, the WM-side client retains the startup-window stdout
(bounded) in addition to the subprocess stderr, and surfaces both — as a
Server stdout output: / Server stderr output: tail — in the ServerFailedToStart
error. That error is logged by the WM (reaching subscribed clients as server/logRecords
with source: "wm", per ADR-0049) and returned in the triggering operation's error
response. Capture stops once the port is published; after that the live channel and
er/logRecords take over. This keeps a "runner failed to start" failure legible over the
protocol even though the runner never became reachable. (Regression-tested in
finecode_jsonrpc/tests/test_startup_output_capture.py.)
Lifecycle¶
- WM starts the ER process (per project + env).
- WM sends
initializeand waits for the ER response. - WM sends
initialized. - WM sends
finecodeRunner/updateConfigto bootstrap handlers and services. - ER processes it and returns
{}. - WM sends
finecodeRunner/resolveActionMetato get action meta info. - ER returns a complete map of
configSource → { canonical_source, runs_concurrently, scope, parentActionSource, language }for every action whose class can be imported in this env. Actions that fail to import are omitted. - WM stores these on its
Actiondomain objects before the runner is considered ready. - On shutdown: WM sends
shutdownthenexit.
Multi-env runs: when an action's handlers span more than one env, the WM orchestrates execution segment-by-segment using
actions/runHandlersso that the serialized run context (previousResult) crosses the wire only at actual env boundaries. See Multi-Env Action Orchestration.
Message Catalog¶
WM -> ER¶
Requests
initialize- Standard LSP initialize request.
-
Example params (trimmed):
-
shutdown -
Standard LSP shutdown request.
-
finecodeRunner/updateConfig - Params:
{ "workingDir": string, "projectName": string, "projectDefPath": string, "config": object } - Config shape (top-level):
actions: list of action objects (name,handlers,source,config)action_handler_configs: map of handler source → configservices: list of service declarations (optional)interface(string): fully-qualified path of the service protocol/interface (e.g."finecode_extension_api.interfaces.icommandrunner.ICommandRunner")source(string): fully-qualified path of the implementation classenv(string): execution environment name the service implementation runs independencies(array of strings): dependencies to install intoenvfor this serviceconfig(object, optional): service-specific configuration, structured into aconfig-annotated constructor parameter on the implementation the same way action handlerconfigis (ADR-0056).null/absent when the service takes no config.handlers_to_initialize: map of action name → handler names (optional). When present, the ER eagerly initializes the listed handlers so they are ready before the firstactions/runrequest. When absent or null, no eager initialization is performed and handlers are initialized on first use.logging: logging configuration for this ER instance (optional)defaultLevel(string): global log level, e.g."INFO","DEBUG","TRACE". Overrides the--log-levelstartup value when present.logGroups(object): map of logger-name prefix → level string. Prefix matching applies — a key of"fine_python_ruff"coversfine_python_ruff,fine_python_ruff.linter, etc. The longest matching prefix wins. Example:{ "fine_python_ruff": "TRACE", "finecode_extension_runner": "WARNING" }
-
Result:
{}(empty object) -
finecodeRunner/updateLogging(ADR-0049) - Params:
{ "forward": boolean, "forwardLevel": string } - Result:
{}(empty object) - Toggles ER -> WM log forwarding (see
er/logRecordsbelow). This is a dedicated, process-level control message — unlikefinecodeRunner/updateConfig, applying it never rebuilds the ER'sRunnerContext(no handler/service re-initialization). - The WM sends this only while at least one client is subscribed to WM logs
(
server/subscribeLogs); it disables forwarding again (forward: false) once the last subscriber unsubscribes or disconnects. A newly started ER is brought up to date immediately if a client is already subscribed. Forwarding is therefore zero-cost on the ER when nobody is observing. -
forwardLevelsets the minimum level forwarded to the WM ("TRACE"|"DEBUG"|"INFO"|"SUCCESS"|"WARNING"|"ERROR"|"CRITICAL") and is independent of the ER's own file/stdout log level configured viafinecodeRunner/updateConfig'sloggingblock — enabling forwarding never changes what the ER writes to its own log file. -
finecodeRunner/getInfo - Params:
{} - Result:
{ "logFilePath": "/abs/path/to/runner.log" | null } -
Returns runtime information about the runner. Currently reports the path to the runner's log file, or
nullif logging to a file is not configured. -
actions/run - Params:
{ "actionName": string, "params": object, "options": object | null } - Options keys (camelCase):
meta:{ "trigger": "user|system|unknown", "devEnv": "ide|cli|ai|git_hook|ci", "orchestrationDepth": int }orchestrationDepth: cross-boundary hop counter, defaults to0. The ER propagates it unchanged viaRunActionMeta.orchestration_depth.partialResultToken:int | string(used to correlate$/progress)resultFormats:["json", "string"](defaults to["json"])callerKwargs(object | null): serializedCallerRunContextKwargs, ornullwhen none. The ER deserializes it into the action's run contextcaller_kwargsparameter.
- Result (success):
- Result (streamed): used when
partialResultTokenwas provided and all results were delivered via$/progressnotifications. The final response is an explicit completion signal —resultByFormatis intentionally empty. The WM treats this as a valid completion; an emptyresultByFormatwith any other status is a protocol error. - Result (stopped):
- Result (error):
-
Note:
resultByFormatis a JSON-encoded string (not a nested object) — the WM decodes it withjson.loadsafter receiving the response. -
actions/runHandlers - Runs a named subset of an action's handlers sequentially within this ER,
seeding
context.current_resultfrom a prior segment's serialized result. Used by the WM to orchestrate multi-env action runs; not used for single-env actions (those still useactions/run). - Params:
actionName(string): action name as registered viafinecodeRunner/updateConfighandlerNames(list of string): ordered list of handler names to execute; all must belong to this ER's envpreviousResult(object | null): serializedRunActionResult(dataclasses.asdict) from the last handler of the preceding segment, ornullfor the first segment. Reconstructed ascontext.current_resultbefore the first handler inhandlerNamesis invoked.previousContext(object | null): serializedSTATE_TYPEdataclass from the preceding segment's response, ornullfor the first segment or when the context has noSTATE_TYPE. Restored intocontext.statebeforecontext.init()is called, so restored state is visible during initialization.callerKwargs(object | null): serializedCallerRunContextKwargs, forwarded unchanged to all segments. Each segment deserializes it independently into the run contextcaller_kwargsparameter. UnlikepreviousContext, it does not chain across segments — the same dict is passed to every segment.options(object | null): same keys asactions/run.resultFormatsshould be omitted (or[]) for intermediate segments and non-empty only for the final segment of a run.
- Result (success):
{ "status": "success", "result": {"<resultField>": "..."}, "resultByFormat": {"json": {"...": "..."}, "string": "..."}, "returnCode": 0, "context": {"<stateField>": "..."} }result: serializedRunActionResultafter all specified handlers ran (dataclasses.asdict). Pass aspreviousResultto the next segment'sactions/runHandlerscall.resultByFormat: formatted results in the requested formats;{}whenresultFormatswas empty in options.context(object | null): serializedSTATE_TYPEafter handlers ran;nullwhen the context has noSTATE_TYPEorserialize_context()returnsnull. Pass aspreviousContextto the next segment'sactions/runHandlerscall.
- Result (streamed): used when
partialResultTokenwas provided and all results were delivered via$/progress.resultandcontextare still populated for chaining. - Result (stopped):
-
Result (error):
{"error": "message"} -
actions/getPayloadSchemas - Params:
{} - Result:
{ action_name: JSON Schema fragment | null } -
Returns a payload schema for every action currently known to the runner. Each schema has
properties(field name → JSON Schema type object) andrequired(list of field names without defaults).nullmeans the action class could not be imported. -
actions/mergeResults - Params:
{ "actionName": string, "results": list } results: list of serializedRunActionResultobjects (dataclasses.asdict), one per concurrent segment or handler. Used by the WM after a concurrent multi-env run to merge the per-env results into a single final result.-
Result:
{ "merged": <serialized RunActionResult> }or{ "error": "..." } -
actions/reload - Params:
{ "actionName": string } -
Result:
{} -
finecodeRunner/resolveActionMeta - Params:
{}(no params) - Result:
{ "actions": { "<configSource>": { "canonical_source": string, "runs_concurrently": bool, "scope": string, "parentActionSource": string | null, "language": string | null, "fileLoc": string | null }, ... }, "handlers": { "<configSource>": { "canonicalSource": string, "fileLoc": string | null }, ... } }.actionscovers every action whose class can be imported in this env;handlerscovers every handler registered in this env. Actions and handlers that fail to import are omitted entirely from their respective maps.fileLocis"<path>:<lineno>"of the class's source (relative to the project dir when inside it, else absolute), ornullwhen it could not be resolved (e.g. a dynamically built class). Example:{ "actions": { "myext.LintAction": { "canonical_source": "myext.actions.lint.LintAction", "runs_concurrently": true, "scope": "project", "parentActionSource": null, "language": null, "fileLoc": "myext/actions/lint.py:10" } }, "handlers": { "myext.LintHandler": { "canonicalSource": "myext.lint_handler.LintHandler", "fileLoc": "myext/lint_handler.py:20" } } } - Called by the WM after
finecodeRunner/updateConfigcompletes to store all action and handler metadata on itsAction/ActionHandlerdomain objects before the runner is considered ready. The WM usescanonical_sourceas the primary identifier in all subsequent action lookups.parentActionSourceandlanguageare used to servefinecode/getActionsForParentrequests (see ER→WM section). Fields absent from the response (import failure) remainNoneuntil another runner for the same project resolves them; if still unresolved when requested, resolution is retried on demand (seefinecode/getActionsForParentbelow). -
The key of both maps is the config source — the string written in the definition file, which for handlers is almost always a package-level re-export (
myext.LintHandler) rather than the module the class is defined in (myext.lint_handler.LintHandler). A handler'scanonicalSourcebridges the two (ADR-0054). Unlike an action's, it is identity metadata, not a dispatch key: the WM still reaches a handler by traversing its action's handler list, so nothing in dispatch matches on it. Consumers that need to recognize the same handler across projects — anything keying handlers, such as the knowledge extractor — must key oncanonicalSource, because two different aliases can name one handler. -
actions/resolveSource - Params:
{ "source": string }— an arbitrary import-path alias to resolve. - Result:
{ "canonicalSource": string }— the fully qualified class path (cls.__module__ + "." + cls.__qualname__). - Raises a JSON-RPC error if the alias cannot be imported or resolved.
-
Used during action lookup when a caller provides an alias not already known from
finecodeRunner/resolveActionMeta(full ADR-0019 support). -
packages/resolvePath - Params:
{ "packageName": string } - Result:
{ "packagePath": "/abs/path/to/package" }
Notifications
initialized(standard LSP)textDocument/didOpen- Params: standard LSP
DidOpenTextDocumentParams—{ "textDocument": { "uri": string, "languageId": string, "version": int, "text": string } }. - The ER seeds the file's tracked content directly from
text_document.text— it never re-reads the file from disk for this notification.textis the client's buffer and is the source of truth regardless of what's on disk; the file may even no longer exist on disk (e.g. a stale IDE tab left open after the file was deleted), which would otherwise crash a disk read. textDocument/didChangetextDocument/didClose$/cancelRequest- Sent by WM when an in-flight request should be cancelled.
ER -> WM¶
Requests
workspace/applyEdit- Standard LSP request for applying workspace edits.
-
WM forwards this to its active client (IDE) if available.
-
projects/getRawConfig - Params:
{ "projectDefPath": "/abs/path/to/project/finecode.toml" } - Result:
{ "config": "<stringified JSON config>" } -
Used by ER during
finecodeRunner/updateConfigto resolve project config. -
workspace/getWorkspaceEditablePackages - Params:
{} - Result:
{ "packages": { "<pkg_name>": "/abs/posix/path", ... } } -
Returns the workspace-level editable-package map resolved from
finecode-workspace.toml(see ADR-0029). The WM resolves this map once duringworkspace/addDirand caches it for the lifetime of the workspace context. -
workspace/getProjectPaths - Params:
{} - Result:
{ "projects": [{ "path": "/abs/path/to/project", "configStatus": "valid" }, ...] } configStatusis one of"valid"(has FineCode config),"no_config"(no FineCode config, expected), or"invalid"(config present but invalid).-
Returns all projects currently known to the WM with their config status. Handlers that need to run actions should filter to
"valid"projects only. -
finecode/runActionInProject - Params:
actionSource(string): fully qualified import path of the action class —f"{cls.__module__}.{cls.__qualname__}"(e.g."myext.actions.lint.LintAction"). Must not be a re-exported alias such as"myext.LintAction". The WM resolves the action name by matching against the canonical source reported byfinecodeRunner/resolveActionMeta; a re-exported path will not match and the request will fail.payload(object): serialized action payload (dataclasses.asdict)meta(object):{ "trigger": string, "devEnv": string, "orchestrationDepth": int }callerKwargs(object | null): serializedCallerRunContextKwargs, ornullwhen none. The WM passes it through opaquely to the target ER.
- Result:
{ "result": <json result object>, "returnCode": 0|1 } -
Runs the action at project scope (all env-runners of the ER's own project). WM enforces
OrchestrationPolicy.max_recursion_depthbefore dispatching. -
finecode/getActionsForParent(ADR-0045) - Params:
{ "parentActionSource": string }— fully qualified import path of a parent action class. - Result:
{ "subactions": [{ "source": string, "canonicalSource": string | null, "language": string }, ...] }— every action in this project whose resolvedPARENT_ACTIONmatchesparentActionSource, regardless of which env owns its handler. -
Used by an ER's
get_actions_for_parentto discover subactions that live in a different env than the caller — an ER only ever knows the actions its own env executes, so cross-env subaction discovery is delegated to the WM, which has the full project-wide action topology. Actions not yet resolved by any runner are resolved on demand (starting the handler's env if needed) before being checked againstparentActionSource; an action that still cannot be resolved is simply excluded rather than failing the whole request. -
finecode/listWorkspaceActions - Params:
{}(no params) - Result:
{ "actions": [{ "name": string, "source": string, "canonicalSource": string | null, "scope": string | null, "project": string, "language": string | null, "parentActionSource": string | null, "fileLoc": string | null, "handlers": [{ "name": string, "source": string, "canonicalSource": string | null, "env": string, "fileLoc": string | null }, ...] }, ...] }— the aggregated action/handler registry across every project and env in the workspace. - Backs the
IWorkspaceActionRegistryservice (see Services). An ER only ever knows the actions its own env executes, so this cross-env picture can only come from the WM. - Values come straight from the WM's resolved domain objects, so a field is
nullwhen the runner that would resolve it has not started yet (or could not import the class). Callers must toleratenullon every optional field rather than assuming a fully-populated registry — notablycanonicalSource, which is populated per env by that env's own runner. -
On both actions and handlers,
sourceis the config-facing alias andcanonicalSourceis the module the class is actually defined in. Key entities bycanonicalSource: the same action or handler is reachable under several aliases, and the WM does not deduplicate the rows (a PROJECT-scope action appears once per project that registers it). -
finecode/runActionInWorkspace - Params:
actionSource(string): fully qualified import path of the action class — same constraint asfinecode/runActionInProjectabove.payload(object): serialized action payloadmeta(object):{ "trigger": string, "devEnv": string, "orchestrationDepth": int }projectPaths(list[string] | null): explicit POSIX project paths, ornullfor all projects that declare the actionconcurrently(boolean, defaulttrue): run projects concurrently.
- Result:
{ "resultsByProject": { "<posix path>": <json result>, ... } } - Fans out the action across the specified projects (or all projects that declare it). WM enforces
OrchestrationPolicy.max_project_fanoutbefore dispatching.
Notifications
$/progress- Params:
{ "token": <token>, "value": "<stringified JSON partial result>" } - The
tokenmust matchpartialResultTokenfromactions/runoractions/runHandlers. valueis a JSON string produced by the ER from a partial run result.-
When
$/progressis used to deliver results, the finalactions/runoractions/runHandlersresponse must havestatus: "streamed"and emptyresult_by_format. See result (streamed) entries above. -
er/logRecords(ADR-0049) - Params:
{ "records": [{ "timestamp": number, "level": string, "group": string, "message": string }, ...] } - Sent by the ER only while forwarding is enabled (see
finecodeRunner/updateLoggingabove); otherwise the ER never sends this notification. Records are raw/unredacted — redaction happens at the WM boundary, not in the ER — and are unbatched (one send per emitted record); the WM re-batches before relaying to subscribed clients asserver/logRecords(seedocs/wm-protocol.md), tagging each record'ssourceas"runner:<env>@<project>".
WM obligation: bridging both delivery modes to callers¶
When the WM runs an action on behalf of a caller that supplied a
partialResultToken (via actions/runBatch), it must forward the result to
the caller as actions/partialResult notifications regardless of which ER
delivery mode was used:
-
Streaming mode (
status: "streamed", emptyresultByFormat): each$/progressnotification is forwarded as anactions/partialResultnotification as it arrives. The final RPC response carries no result data. -
Direct result mode (
status: "success", non-emptyresultByFormat): no$/progressnotifications are sent by the ER. After the RPC call completes the WM must emit a singleactions/partialResultnotification containing theresultByFormatfrom the RPC response.
The two modes are mutually exclusive: a handler that uses $/progress
produces an empty resultByFormat; one that does not produces a non-empty
resultByFormat. The WM detects which mode was used by checking whether any
$/progress notifications arrived before the RPC call returned.
Multi-Env Action Orchestration¶
When an action's handlers span more than one env, the WM cannot delegate the
whole run to a single ER via actions/run. Instead the WM becomes the
orchestrator and drives execution using actions/runHandlers.
Sequential mode (default)¶
The WM groups the action's handlers into consecutive same-env segments:
handlers: [h1/env1, h2/env1, h3/env1, h4/env2]
segments: [(env1, [h1, h2, h3]), (env2, [h4])]
handlers: [h1/env1, h2/env2, h3/env1]
segments: [(env1, [h1]), (env2, [h2]), (env1, [h3])]
Execution:
- WM calls
actions/runHandlersfor segment 1 withpreviousResult: nullandpreviousContext: null. - For each subsequent segment, WM calls
actions/runHandlerson that segment's ER withpreviousResultset to theresultreturned by the previous call, andpreviousContextset to thecontextreturned by the previous call. The ER reconstructspreviousResultascontext.current_resultandpreviousContextascontext.statebeforecontext.init()is called. - If any call returns
status: "stopped", WM stops the chain and returns that result to the caller. resultFormatsis passed only in the final segment's options — earlier segments returnresultByFormat: {}to avoid unnecessary serialization.- WM assembles the final response from the last segment's
resultandresultByFormat.
Concurrent mode¶
The WM groups handlers by env (order within an env does not matter for concurrent execution):
Execution:
- WM dispatches
actions/runHandlersto all env groups in parallel, all withpreviousResult: nullandpreviousContext: null— parallel groups have no linear context chain to thread. - WM collects all
resultobjects from the parallel calls. - WM calls
actions/mergeResultson any available ER for the action, passing the collectedresultobjects. - The merged result and its formatted representation form the final response.
Single-env actions¶
When all handlers are in the same env, the WM uses actions/run — a single
delegated call where the ER manages handler sequencing internally.
actions/runHandlers is only used when handlers span multiple envs.
walRunId continuity¶
The WM generates a single walRunId for the whole logical action run and
passes it in every actions/runHandlers call's options. Each ER emits WAL
events tagged with that ID for the handler(s) it executes, so traces can be
correlated across envs for the same logical run.
Error Handling and Cancellation¶
- JSON-RPC errors are used for protocol-level failures.
- Command-level errors are returned via
{ "error": "..." }in command results. - WM cancels in-flight requests by sending
$/cancelRequestwith the request id.
Document Sync Notes¶
WM forwards open-file events to ER so actions can operate on in-memory document
state. ER may send workspace/applyEdit when handlers modify files; WM applies
these edits via its active client when possible.
The WM keeps its own copy of every open document's content
(WorkspaceContext.opened_documents), populated from the IDE's own
didOpen/didChange notifications, so it can re-supply the current content
to an ER that (re)starts while the file is already open — see
send_opened_files in runner_manager.py. That re-supply must forward the
already-known text unchanged; reconstructing a fresh TextDocumentInfo
without text would make the ER seed an empty buffer instead of the real
content on every runner restart.