EasyMod VS Code Extension
VS Code sidebar chat extension for EasyMod backend gateway APIs.
What It Does
- Adds an
EasyMod Activity Bar view with a webview chat panel.
- Sends chat requests through backend
POST /api/vscode/send (including streaming).
- Supports extension sign-in with auth code exchange + refresh token flow.
- Supports organization-aware requests (
org_id) and model loading.
- Chat model dropdown includes an Auto option (sends
model: "auto"; backend performs
routing selection) alongside concrete models; a small resolved-model badge is shown on
assistant messages that were answered by an Auto turn. Auto is currently withheld for the
Grok provider — a Grok Auto turn loses the patch card, so pick a concrete Grok model for
edit requests until the gateway fix lands.
- Provider dropdown is dynamic: it lists every provider the backend's model list actually
returns (e.g. OpenAI, Anthropic, DeepSeek, Gemini, Grok), with an Auto option always
first — no provider needs a code change to show up once the backend serves its models.
- Models the workspace cannot use yet (e.g. a paid model on a Trial or Free plan without
top-up or enough referral credits) stay in the model dropdown but are greyed out with
"(upgrade required)" and the backend's reason as a tooltip, instead of failing with
"requires a paid plan" after you send. A saved selection that has become locked falls back
to the first usable model.
- Includes local workspace tool loop (
rg, file read, file list) for retrieval-style workflows.
In a multi-root workspace, a bare rg search (no path argument) now searches EVERY open
workspace root, not just the first — matches from a non-primary root come back prefixed with
that root's folder name.
- Adds persistent workspace memory index (
.easymod/workspace-memory.json) for cross-language symbol/impact recall between turns. Remembered file content is validated against disk each time it's
reused: a changed file is re-read and refreshed automatically, and a removed file is dropped
from memory, so the assistant no longer quotes a file's pre-edit content back at you. Reused
session context snippets (the per-conversation context ledger) are validated the same way and
are dropped when the file no longer matches. In a multi-root workspace, a file read from any
open folder is remembered under THAT folder's own memory snapshot (not always the primary
root). Later turns rank remembered chunks across every open root under one global six-chunk /
token budget, then validate only the globally selected files against their owning roots. Once
per session each root's snapshot is also swept to drop entries that can no longer be resolved.
- Supports assistant patch preview/apply from unified diff output with workspace safety checks,
replay protection, and preservation of existing CRLF/LF line endings.
- Supports multiple local chat sessions (new/switch/rename/delete/clear).
- Keeps in-flight assistant streaming alive across Activity Bar/sidebar switches by retaining and rehydrating the chat webview state.
Requirements
- VS Code
^1.90.0
- Node/npm for build and test
- EasyMod backend reachable from
easymod.apiUrl
Setup
npm install
npm run compile
For development:
npm run watch
Run extension in Extension Development Host with F5.
Test
npm test
This rebuilds out/ and runs Node tests from compiled output (out/test/**/*.test.js) if present.
npm run compile runs the TypeScript strict compile check.
npm run watch keeps TypeScript compiling during extension development.
npm test runs the repository safety gates (check-db-safety, check-test-suite), rebuilds out/, and then runs the compiled Node test suite.
Commands
EasyMod: Open Chat Panel
EasyMod: Focus Chat View
EasyMod: Sign In
EasyMod: Sign Out
EasyMod: Switch Organization
EasyMod: Set Manual JWT Session
EasyMod: Toggle Full File Access
Diagnostic — listed in the Command Palette only while easymod.enableDeveloperDebugPage is on,
and off unless configured (see Direct-gateway probe):
EasyMod: Probe Direct Gateway (single turn)
EasyMod: Probe Direct Gateway (worker release under load)
EasyMod: Run Direct Turn (spends credit) — see Direct turn
Settings (easymod.*)
Core connection/auth:
apiUrl (default: https://backend.easymodularity.com)
model (default: empty; uses the EasyMod default chat model)
defaultOrgId (default: empty)
extensionAuthBridgeUrl (default: https://easymodularity.com/extension-auth/bridge/start; set empty to use <apiUrl>/extension-auth/bridge/start)
extensionId (default: empty)
directTurns (default: true) — send chat straight to the gateway on a
per-turn token instead of proxying it through the backend. See
Direct turns.
directGatewayUrl (default: empty — diagnostic only, see Direct-gateway probe; chat never reads it)
directGatewayApiKey (default: empty — diagnostic only; use a dev gateway's key)
mcpJson (default: empty JSON string field)
Direct turns
With directTurns on, a chat send is admitted by the backend and then goes
straight to the gateway:
extension ──POST /api/turns──▶ backend reserves credit, binds the model,
writes the user's message, mints a
turn token
extension ──POST /turns/<id>/chat──▶ gateway streams the answer
gateway ──internal callback──▶ backend settles, files the reply
Nothing about billing or history moves: the backend still prices the turn, still
owns the ledger, and still writes both halves of the conversation. What changes
is that the answer no longer travels through it.
The address is not configurable — the admission response names the gateway,
so a gateway that moves does not strand a client that was configured before it
moved. directGatewayUrl is a separate, diagnostic-only setting and chat never
reads it.
Tool loops go direct too. A loop turns one user message into several model
calls, and on this path each call is its own admitted turn. Only the first
records the question; the rest name the conversation and ask nothing, so the
transcript ends up with one question and one answer — the intermediate turns
produce tool calls and no text, and nothing empty is filed.
What still stays on the proxied path is anything admission cannot serve. An
issuer that is switched off, a backend that is down, a response with no token:
all fall back silently, and the turn runs exactly as it did before. A policy
answer does not fall back — an insufficient-credit refusal is shown, because the
proxied path would reserve credit and fail for the same reason on the way to a
worse message.
An admitted turn is sent once. A turn token is claimed exactly once, so the
retries the proxied path uses (a 5xx retry, a limiter wait-and-resend, the
non-stream fallbacks) are skipped rather than turned into a guaranteed 409.
Turn it off and chat returns to the proxied path on the next message — no reload.
Context and prompt assembly:
includeSelection (default: true)
includeWholeFile (default: false)
contextMode (focused | fullActiveFile, default: focused)
maxContextChars (default: 32000)
maxPromptChars (default: 52000)
reservedResponseChars (default: 12000)
maxPromptTokens (default: 32000)
maxContextTokens (default: 12000)
reservedResponseTokens (default: 4000)
historyMaxTokens (default: 3000)
memorySummaryMaxTokens (default: 800)
Retrieval and memory behavior:
contextLedgerEnabled (default: true)
contextLedgerMaxEntries (default: 24)
minRelevanceScore (default: 24)
retrievalChunkLines (default: 80)
retrievalChunkOverlapLines (default: 8)
retrievalMaxFiles (default: 80)
retrievalMaxSnippets (default: 3)
retrievalPerFileCharLimit (default: 120000)
maxToolResultChars (default: 30000)
Patch and tooling behavior:
autoApplyPatchWithConfirmation (default: true, legacy/unused)
enableWorkspaceToolLoop (default: true)
commandLoopEnabled (default: true)
commandLoopMaxRetries (default: 2)
commandExecTimeoutMs (default: 120000)
alwaysAutoRunCommands (default: false)
autoRunProjectStartup (default: false)
fullFileAccess (default: false)
enableDeveloperDebugPage (default: false)
apiKey is legacy/unused in JWT backend mode.
Patch flow notes:
fullFileAccess: true means new assistant patches auto-apply, and it also turns on trusted command auto-run plus startup auto-run.
fullFileAccess: false means new assistant patches stay in preview mode until manually applied from the latest patch card.
- Replaying an already-satisfied patch is reported as already applied without rewriting the
file; a structurally new diff can still intentionally repeat content. Existing CRLF/LF files
keep their line-ending convention through patch writes.
- When a prompt explicitly names a target workspace root (e.g.
in easymod-api workspace, … or in easymod-api, create …) and that name matches no existing root, auto-apply is blocked before any patch is applied; the extension surfaces the available roots and asks you to re-send.
alwaysAutoRunCommands: true skips the manual confirmation dialog only for trusted command runs that pass the extension policy.
autoRunProjectStartup: true lets EasyMod auto-start exactly one trusted startup command after prompts like "serve this project", "run this app", or natural variants such as "help me serve it". Auto-run is now allowlist-based: only common local startup commands such as npm run dev, npm start, npx serve, npx http-server, python -m http.server, php artisan serve, vite, and similar dev-server patterns qualify. Before launching a long-running startup command in the terminal, EasyMod now performs a short startup probe. If the command fails immediately, the captured stderr/stdout is fed into the command repair loop instead of being lost in terminal-only output. It still skips auto-run when there are multiple commands, the command is not startup-like, the command is outside the trusted auto-run allowlist, or a patch is still waiting for manual apply. The terminal working directory is anchored at the workspace root whose basename matches the command's leading cd <target> prefix, and the redundant root-name segment is stripped from the cd before the command is sent to the terminal (e.g. cd easymod-api/calculator && npm run dev spawns in the easymod-api root and sends cd calculator && npm run dev; bare-root cd easymod-api && npm run dev drops the cd entirely and sends npm run dev). Commands with no cd prefix or an unrecognized first segment fall back to the active-editor folder or the first workspace folder, and are sent verbatim.
Direct-gateway probe
A diagnostic path that exists to answer one question before anyone commits to
rebuilding the transport: if the extension reads the stream straight from the
gateway instead of through the Laravel backend, does the backend hold zero
workers?
Today the backend proxies the byte stream, which pins an Octane worker for the
whole turn — a long turn occupies a PHP process that cannot serve anyone else.
It is not a feature and no chat turn can reach it. Both commands refuse to
run until two settings are filled in, and they are empty by default:
Both values are the ones the backend already uses to reach the gateway — its
EASYMOD_API_URL and EASYMOD_API_KEY, which are PORT and API_KEY in
easymod-api/.env:
"easymod.directGatewayUrl": "http://127.0.0.1:4000", // easymod-api PORT
"easymod.directGatewayApiKey": "dev-easymod-api-key" // easymod-api API_KEY
Put them in User settings, not this repo's .vscode/settings.json — that
file is tracked in git, so a key placed there is one git add away from being
committed. User settings apply in the Extension Development Host too.
Two credentials, and why the probe prefers the second
|
shared API_KEY |
per-turn token |
| Opens |
every /v1 route |
exactly one turn |
| Expires |
never |
minutes |
| Carries identity |
no |
user_id / org_id |
| Carries a budget |
no |
yes |
| Can ship to users |
no |
yes, once the backend mints them |
The probe cannot mint a turn token, by design. It used to: an
easymod.directGatewayTurnSecret setting held the gateway's signing secret and
the probe signed its own tokens, back when the backend had no issuer to stand in
for. The backend has one now — POST /api/turns (one endpoint for every
client, which names itself in the body), which checks
auth, membership and plan, reserves the credit, records the turn, and only
then signs — so the stand-in is gone, and the setting with it. A client that can
sign is a client that can assert any identity and any budget it likes.
What remains is a credential-free check of the turn route, run automatically
after the single-turn probe: POST /turns/<id>/chat with no Authorization
header at all.
| It answers |
Meaning |
| 401 |
the healthy answer — the route is deployed and its verifier is live |
| 404 |
the route is absent: an older gateway, or directGatewayUrl points at the backend |
| 503 |
the gateway has no GATEWAY_TURN_TOKEN_SECRET and cannot verify tokens at all |
| any 2xx |
alarming — the turn route served a request that presented nothing. The report says so |
Driving a real turn is the issuer's job, not this probe's.
Then turn on easymod.enableDeveloperDebugPage (the diagnostic commands are listed in the
Command Palette only while it is on) and, from the Command Palette:
EasyMod: Probe Direct Gateway (single turn) — proves the pipe. If this
fails, the load run's numbers mean nothing. The two failures that actually
happen first are 401 (key mismatch) and 404 (the URL points at the
backend, not the gateway).
EasyMod: Probe Direct Gateway (worker release under load) — fires N
concurrent turns at the gateway while sampling the backend once a second, and
reports whether the backend stayed responsive.
Output goes to its own EasyMod Probe output channel, which the commands
reveal — deliberately not the shared debug channel, which is silent unless
enableDeveloperDebugPage is on.
Three things that decide whether a run is meaningful:
- Both URLs must point at the same environment. The probe loads
directGatewayUrl and samples apiUrl. A local gateway sampled against a
remote backend gives a flat backend column because that backend was never
under load — which reads as a pass. The confirmation dialog prints both URLs
and warns when one is local and the other is not.
- The backend sample hits an arbitrary path and a 404 is fine. A PHP server
queues requests when every worker is busy, so a route that does not exist
still gets slow when the pool is saturated. No auth, no new backend route.
- This run loads the gateway only. A flat backend column proves the direct
path consumed no backend capacity. It says nothing about what the backend
would have done under the same load — for that contrast, send the same number
of turns through normal chat while a run is sampling.
Local note: npm run dev:workspace starts the backend as
php artisan serve --port=8010, which is a single-process dev server, and
php artisan octane:start cannot run on Windows at all (pcntl/SIGINT are
POSIX-only). Production runs RoadRunner with num_workers: 4 (.rr.yaml). So a
local run demonstrates the mechanism — direct turns hold zero backend capacity
— but the magnitude of the ceiling it removes is not transferable from a
one-request-at-a-time dev server to a four-worker pool.
What a green result does not mean. A direct turn bypasses the backend, so it
is not billed, it uses the gateway's shared API key (which can never ship
to users), and it bypasses the backend's admission and rate limiting. Resume
after a dropped connection is untouched. A clean run means the mechanism holds
— not that this path is shippable.
Direct turn
The probe above proves the mechanism. This runs a real turn on it: the
backend admits one, reserves credit, signs a short-lived token, and the
extension takes that token straight to the gateway and reads the stream itself.
EasyMod: Run Direct Turn (spends credit) does exactly what its title says. It
is diagnostic, no chat turn reaches it, and it needs easymod.directGatewayUrl
set and you signed in. The spending is the point — an unspent turn proves
nothing about admission or settlement, which is what this exists to check.
The four steps, and the one hard part
1. build the body you are going to send
2. MEASURE it -> that number is `budget_tokens`
3. POST <backend>/api/turns with your session JWT
-> a token bound to ONE turn, model and client
4. POST <gateway>/turns/:id/chat with THAT token -> read the SSE yourself
The HTTP is unremarkable. What a second client (web, mobile) will get wrong is
what to do when one of those says no, because the refusals are not
interchangeable:
| Step |
Status |
Means |
The client must |
| admission |
404 |
the issuer is switched off here |
fall back, silently |
| admission |
402 |
out of credit |
show it — NEVER fall back |
| admission |
403 |
not entitled |
show it |
| admission |
5xx / network |
infrastructure |
fall back, like the 404 |
| send |
413 |
body exceeds what was declared |
trim, retry on the SAME token |
| send |
409 |
this token was already spent |
stop — a retry is a second charge |
| send |
403 |
wrong model or client for this token |
a client bug; do not retry |
404 is the normal answer from a deployment with the issuer off, which
during a rollout is most of them — surfacing it would show every user an error
for a feature they never asked for. 402 is the opposite: falling back is
actively harmful, because the proxied path reserves credit first and then
refuses for the same reason, so the user waits longer, reads a worse message,
and has credit locked up for a turn that was never going to run.
413 is the only one a client recovers from unaided, and that is by design: the
gateway's budget guard runs before the claim, so the turn is still
admitted and the same token works for a smaller body.
The measurement is a contract, not a detail
src/services/turnInputBudget.ts must produce exactly the number
easymod-api/src/services/turnInputBudget.ts computes — the gateway recomputes
it from the body it receives and refuses the turn if the body measures larger.
One token of drift is a turn refused for no reason the user can see, so both
repositories assert the same table of golden vectors: change one side and the
other side's test fails.
It counts what a naive estimate loses — tool schemas, file bodies, tool-call
turns — and prices an image as a flat constant, never its base64 length:
providers bill images by pixel dimensions, so the same screenshot costs the same
as a 40KB JPEG or a 4MB PNG.
Two details that look like typos
- An Auto turn declares
auto at admission but sends an empty model on the
wire. The token records that the reservation was not priced against one
specific model; the empty body field is what makes the gateway's routing pick
one.
- The body's
source must equal the client the token was admitted for. The
gateway forks its response handling on it, so a turn admitted as one client and
run as another executes a branch the backend never authorised — refused 403.
What this does not do yet
Chat does not use any of this. gatewayChatClient.ts still goes through the
backend. Repointing it waited on knowing the gateway's SSE frames matched what it
already parses; a live run on 2026-09-07 confirmed they do — every frame is a
bare data: with no event: name, and the text arrives as
choices[0].delta.content, both of which that client already handles.
Local Dev Tip
For local development against a local backend/frontend, use workspace overrides in .vscode/settings.json instead of changing defaults in package.json.
Example:
{
"easymod.apiUrl": "http://127.0.0.1:8010",
"easymod.extensionAuthBridgeUrl": "https://easymodularity.com/extension-auth/bridge/start",
"easymod.autoRunProjectStartup": true,
"easymod.fullFileAccess": false,
"easymod.enableDeveloperDebugPage": true
}
Notes
- Backend remains source of truth for auth, org access, and request handling.
- Extension-side tool loop and context assembly are configurable and can be disabled.
- Patch writes are guarded by workspace boundary checks and optional confirmation.
- In-flight assistant streams continue in the extension host even if the EasyMod sidebar is temporarily hidden; reopening the view rehydrates the current streamed text instead of starting from a blank panel.
Stream Log Semantics
chat stream chunk means raw bytes/chunks were read from backend SSE/stream transport.
assistant delta received means user-visible assistant text delta was emitted into the extension turn pipeline.
ui first flush is tied to the first assistant delta received, not the first chat stream chunk.
- In tool-loop or multi-turn synthesis flows,
chat stream chunk can appear long before first UI flush.
Example A (small-talk, fast path):
chat stream chunk at ~5ms
assistant delta received at ~600ms
ui first flush immediately after first delta
Example B (tool-loop heavy prompt):
- early
chat stream chunk events appear quickly (transport is active)
- multiple loop/follow-up turns happen
- first
assistant delta received may appear much later (for example 50s+)
ui first flush then occurs right after that first assistant delta