Skip to content

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 on 127.0.0.1 and announces it on stdout as Serving 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-level sets the global default log level for the ER process (default: INFO); fine-grained per-group overrides are delivered later via finecodeRunner/updateConfig
  • --debug enables a debugpy attach flow before WM connects (the debug port is announced on stdout as Debug 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

  1. WM starts the ER process (per project + env).
  2. WM sends initialize and waits for the ER response.
  3. WM sends initialized.
  4. WM sends finecodeRunner/updateConfig to bootstrap handlers and services.
  5. ER processes it and returns {}.
  6. WM sends finecodeRunner/resolveActionMeta to get action meta info.
  7. 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.
  8. WM stores these on its Action domain objects before the runner is considered ready.
  9. On shutdown: WM sends shutdown then exit.

Multi-env runs: when an action's handlers span more than one env, the WM orchestrates execution segment-by-segment using actions/runHandlers so 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):

    {
      "processId": 12345,
      "clientInfo": {"name": "FineCode_WorkspaceManager", "version": "0.1.0"},
      "capabilities": {},
      "workspaceFolders": [{"uri": "file:///path/to/project", "name": "project"}],
      "trace": "verbose"
    }
    

  • 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 → config
    • services: 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 class
    • env (string): execution environment name the service implementation runs in
    • dependencies (array of strings): dependencies to install into env for this service
    • config (object, optional): service-specific configuration, structured into a config-annotated constructor parameter on the implementation the same way action handler config is (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 first actions/run request. 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-level startup value when present.
    • logGroups (object): map of logger-name prefix → level string. Prefix matching applies — a key of "fine_python_ruff" covers fine_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/logRecords below). This is a dedicated, process-level control message — unlike finecodeRunner/updateConfig, applying it never rebuilds the ER's RunnerContext (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.
  • forwardLevel sets 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 via finecodeRunner/updateConfig's logging block — 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 null if 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 to 0. The ER propagates it unchanged via RunActionMeta.orchestration_depth.
    • partialResultToken: int | string (used to correlate $/progress)
    • resultFormats: ["json", "string"] (defaults to ["json"])
    • callerKwargs (object | null): serialized CallerRunContextKwargs, or null when none. The ER deserializes it into the action's run context caller_kwargs parameter.
  • Result (success):
    {
      "status": "success",
      "resultByFormat": "{\"json\": {\"...\": \"...\"}}",
      "returnCode": 0
    }
    
  • Result (streamed): used when partialResultToken was provided and all results were delivered via $/progress notifications. The final response is an explicit completion signal — resultByFormat is intentionally empty. The WM treats this as a valid completion; an empty resultByFormat with any other status is a protocol error.
    {
      "status": "streamed",
      "resultByFormat": "{}",
      "returnCode": 0
    }
    
  • Result (stopped):
    {
      "status": "stopped",
      "resultByFormat": "{\"json\": {\"...\": \"...\"}}",
      "returnCode": 1
    }
    
  • Result (error):
    {"error": "message"}
    
  • Note: resultByFormat is a JSON-encoded string (not a nested object) — the WM decodes it with json.loads after receiving the response.

  • actions/runHandlers

  • Runs a named subset of an action's handlers sequentially within this ER, seeding context.current_result from a prior segment's serialized result. Used by the WM to orchestrate multi-env action runs; not used for single-env actions (those still use actions/run).
  • Params:
    • actionName (string): action name as registered via finecodeRunner/updateConfig
    • handlerNames (list of string): ordered list of handler names to execute; all must belong to this ER's env
    • previousResult (object | null): serialized RunActionResult (dataclasses.asdict) from the last handler of the preceding segment, or null for the first segment. Reconstructed as context.current_result before the first handler in handlerNames is invoked.
    • previousContext (object | null): serialized STATE_TYPE dataclass from the preceding segment's response, or null for the first segment or when the context has no STATE_TYPE. Restored into context.state before context.init() is called, so restored state is visible during initialization.
    • callerKwargs (object | null): serialized CallerRunContextKwargs, forwarded unchanged to all segments. Each segment deserializes it independently into the run context caller_kwargs parameter. Unlike previousContext, it does not chain across segments — the same dict is passed to every segment.
    • options (object | null): same keys as actions/run. resultFormats should 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: serialized RunActionResult after all specified handlers ran (dataclasses.asdict). Pass as previousResult to the next segment's actions/runHandlers call.
    • resultByFormat: formatted results in the requested formats; {} when resultFormats was empty in options.
    • context (object | null): serialized STATE_TYPE after handlers ran; null when the context has no STATE_TYPE or serialize_context() returns null. Pass as previousContext to the next segment's actions/runHandlers call.
  • Result (streamed): used when partialResultToken was provided and all results were delivered via $/progress. result and context are still populated for chaining.
    {
      "status": "streamed",
      "result": {"<resultField>": "..."},
      "resultByFormat": {},
      "returnCode": 0,
      "context": {"<stateField>": "..."}
    }
    
  • Result (stopped):
    {
      "status": "stopped",
      "result": {"<resultField>": "..."},
      "resultByFormat": {"json": {"...": "..."}},
      "returnCode": 1,
      "context": {"<stateField>": "..."}
    }
    
  • 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) and required (list of field names without defaults). null means the action class could not be imported.

  • actions/mergeResults

  • Params: { "actionName": string, "results": list }
  • results: list of serialized RunActionResult objects (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 }, ... } }. actions covers every action whose class can be imported in this env; handlers covers every handler registered in this env. Actions and handlers that fail to import are omitted entirely from their respective maps. fileLoc is "<path>:<lineno>" of the class's source (relative to the project dir when inside it, else absolute), or null when 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/updateConfig completes to store all action and handler metadata on its Action/ActionHandler domain objects before the runner is considered ready. The WM uses canonical_source as the primary identifier in all subsequent action lookups. parentActionSource and language are used to serve finecode/getActionsForParent requests (see ER→WM section). Fields absent from the response (import failure) remain None until another runner for the same project resolves them; if still unresolved when requested, resolution is retried on demand (see finecode/getActionsForParent below).
  • 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's canonicalSource bridges 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 on canonicalSource, 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. text is 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/didChange
  • textDocument/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/updateConfig to 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 during workspace/addDir and caches it for the lifetime of the workspace context.

  • workspace/getProjectPaths

  • Params: {}
  • Result: { "projects": [{ "path": "/abs/path/to/project", "configStatus": "valid" }, ...] }
  • configStatus is 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 by finecodeRunner/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): serialized CallerRunContextKwargs, or null when 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_depth before 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 resolved PARENT_ACTION matches parentActionSource, regardless of which env owns its handler.
  • Used by an ER's get_actions_for_parent to 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 against parentActionSource; 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 IWorkspaceActionRegistry service (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 null when the runner that would resolve it has not started yet (or could not import the class). Callers must tolerate null on every optional field rather than assuming a fully-populated registry — notably canonicalSource, which is populated per env by that env's own runner.
  • On both actions and handlers, source is the config-facing alias and canonicalSource is the module the class is actually defined in. Key entities by canonicalSource: 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 as finecode/runActionInProject above.
    • payload (object): serialized action payload
    • meta (object): { "trigger": string, "devEnv": string, "orchestrationDepth": int }
    • projectPaths (list[string] | null): explicit POSIX project paths, or null for all projects that declare the action
    • concurrently (boolean, default true): 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_fanout before dispatching.

Notifications

  • $/progress
  • Params: { "token": <token>, "value": "<stringified JSON partial result>" }
  • The token must match partialResultToken from actions/run or actions/runHandlers.
  • value is a JSON string produced by the ER from a partial run result.
  • When $/progress is used to deliver results, the final actions/run or actions/runHandlers response must have status: "streamed" and empty result_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/updateLogging above); 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 as server/logRecords (see docs/wm-protocol.md), tagging each record's source as "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", empty resultByFormat): each $/progress notification is forwarded as an actions/partialResult notification as it arrives. The final RPC response carries no result data.

  • Direct result mode (status: "success", non-empty resultByFormat): no $/progress notifications are sent by the ER. After the RPC call completes the WM must emit a single actions/partialResult notification containing the resultByFormat from 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:

  1. WM calls actions/runHandlers for segment 1 with previousResult: null and previousContext: null.
  2. For each subsequent segment, WM calls actions/runHandlers on that segment's ER with previousResult set to the result returned by the previous call, and previousContext set to the context returned by the previous call. The ER reconstructs previousResult as context.current_result and previousContext as context.state before context.init() is called.
  3. If any call returns status: "stopped", WM stops the chain and returns that result to the caller.
  4. resultFormats is passed only in the final segment's options — earlier segments return resultByFormat: {} to avoid unnecessary serialization.
  5. WM assembles the final response from the last segment's result and resultByFormat.

Concurrent mode

The WM groups handlers by env (order within an env does not matter for concurrent execution):

handlers:  [h1/env1, h2/env2, h3/env1]
groups:    [(env1, [h1, h3]), (env2, [h2])]

Execution:

  1. WM dispatches actions/runHandlers to all env groups in parallel, all with previousResult: null and previousContext: null — parallel groups have no linear context chain to thread.
  2. WM collects all result objects from the parallel calls.
  3. WM calls actions/mergeResults on any available ER for the action, passing the collected result objects.
  4. 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 $/cancelRequest with 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.