Repository navigation
A headless CLI for mcp - #11
Merged
Merged
Conversation
- src/log.ts: the LogEntry, ImportPrompt and ImportLog types moved out of console.ts. The core no longer imports anything from the UI. - src/platform.ts: a Platform interface with one method, importModule. - platform-browser.ts imports through blob: URLs and now holds readWasm. - platform-node.ts imports from temporary files, which keeps stack traces short. - loadComponent and instantiate take the platform as an argument. - WASI in Node uses the browser build of preview2-shim. The Node build calls process.exit on wasi:cli/exit, passes the host's environment to the guest, ignores our output callbacks, and keeps global state. Node bundles must therefore resolve the shim with the browser condition. - Bindgen is now transpiled without --no-nodejs-compat, so it loads its core wasm with fs under Node. Only that compat code changed in src/obj/bindgen.js. - tsconfig.json leaves out platform-node.ts. It is typechecked through tests/tsconfig.json instead. - Node tests: tests/node/core.test.ts is bundled by scripts/build-node-tests.mjs and run with npm run test:node. It has 3 tests: - JSPI is available - an import without an implementation waits for its answer (calculator) - stdout and stderr reach the output callbacks (printer)
Every logged call now carries a structured CallRecord, and the console shows exactly the same text as before. Typecheck, the build, the Node tests (4/4, one of them new) and Playwright (30/30) all pass. Nothing is committed.
The record (src/call-record.ts):
{ kind: 'export' | 'host' | 'import', callKind: 'func' | 'constructor' | 'method' | 'static' | 'drop',
iface, resource?, func, self?, args: string[] /* one WAVE string each */, label }
- kind says who calls whom:
- export: the UI calls the guest.
- host: the UI calls the host directly, e.g. a method of a host resource.
- import: the guest calls the host.
- label is the text the console already showed, built the same way as before.
What changed:
- One builder for every label: describeCall and describeDrop replace eight hand-built label strings in calls.ts, dialog.ts and host-resources.ts.
- Naming helpers moved: qualified, qualifiedResource and unversioned moved from host-resources.ts into call-record.ts, which avoids an import cycle.
- Arguments formatted one by one: wave.ts has formatArgList, which formats each argument separately. It replaces formatArgs, which joined them.
- Loggers take records: ExportsContext.log, the ImportDialogs logger, and the console's logCall and logImport now take a CallRecord instead of a string. App messages that aren't calls, like a failed file load, go through a new logAction(text).
- New lookup: ResourceRegistry.instance(id) finds an instance by id, which drops need for their record.
component-ui now reads and writes WAVE with proxy-component's own wasm-wave fork, compiled into crates/bindgen. Values and handle labels are written byte for byte as proxy-component writes them. The parser accepts the same handle labels proxy-component does, plus two component-ui extensions (#mock and host- ids).
Rust: crates/bindgen
- Dependency: wasm-wave from chenyan2002/wasm-tools, pinned to the commit proxy-component uses (efecaa18). The default wit feature is off, so it doesn't pull in an older wit-parser.
- Interface: a new wave interface in wit/bindgen.wit exports format(type, value) and parse(type, text).
- Types and values cross the boundary as flat node lists, since WIT types can't be recursive. Children are referenced by index, and the root is the last node.
- jco marshals the numbers exactly: bigints, f32, NaN, -0.
- Implementation: src/wave.rs, built on the fork's to_string and from_str.
- Generated code: src/obj is regenerated. The core wasm grew from 308 KB to 383 KB gzipped.
TypeScript: src/wave.ts
- Writing: the hand-written writer is gone. formatValue, formatArgList, formatResult and formatThrown keep their signatures and only convert jco values to nodes, so no caller changed.
- Values that don't match their type (e.g. 3.5 for a u32) are caught in TS and still show as <invalid u32>, instead of being silently converted by jco.
- Parsing: the new parseWave(type, text) returns a form Draft. The existing parseDraft then does the instance-ownership checks and mock creation, exactly as for input typed into a form.
- Handle labels match proxy-component:
- It writes #x-1 for an owned handle and #borrow-x-1 for a borrowed one. Borrowed handles used to be written #borrowed-x-1.
- It reads only those forms. The label must name the handle's resource and carry borrow- exactly for a borrow; anything else is a parse error.
- Extensions: #mock asks for a new mock, and host- ids (e.g. #host-y-1) are accepted. proxy-component would reject both.
- Float fields in forms now accept nan, inf and -inf, the WAVE spellings (draft.ts).
Known issue
wasm-wave ignores record fields the type doesn't have. {x: 1, y: 2} reads as {x: 1}, and a misspelled optional field silently becomes none. This comes from upstream wasm-wave, so proxy-component behaves the same way. It's documented in the gap doc and asserted under a FIXME in the tests, so a fix upstream will show up as a failing test.
- src/driver.ts, the shared driver, which doesn't depend on Node or the browser: - loads and instantiates a component - resolves calls written exactly as the console prints them: iface.func(…), new iface/res(…), iface/res.static(…), #borrow-x-1.method(…) - parses arguments with parseWave - emits events: call, ask, result, error, detail, output - routes prompting imports to a pluggable AnswerSource, and stops a run when that source has no answer or rejects one - src/answers.ts: the answers file and the sequence answer source. - Format: one answer per line, callee[(args)] = <wave>. = !trap traps the guest, #mock is a new mock, and lines starting with // are comments. - Matching: answers are used in call order, and each is checked against the import it names. A method's answer can name the instance (#borrow-y-1.get-a) or any instance of its resource (imports/y.get-a). Pinned arguments are compared as values (via canonicalWave), not as text. - Errors point to the answer's source, e.g. answers.txt:4: the answer is for …, but the guest called …. Answers no import asked for produce a warning. - src/cli.ts: two commands. - inspect lists the exports to call, the imports that ask for answers, and the definitions of named types. - call takes --answers, --answer, --imports <pkg>=<file.js> and --json. - Exit codes: 0 when every call ran, 2 when an import needs an answer, 1 on errors. - On Node versions that need the --experimental-wasm-jspi flag, it restarts itself with it. - Packaging: scripts/build-node.mjs replaces build-node-tests.mjs and builds both the CLI (into dist-cli/, gitignored) and the tests. package.json gets a bin entry and a build:cli script, and test:node builds the CLI first. - Small core changes: - ImportPrompt.answer(draft?) now takes an optional draft and returns whether the value was accepted. - ImportPrompt also carries the import's func. - New helpers: draftErrors in draft.ts, calleesOf in call-record.ts, canonicalWave in wave.ts.
The answers file is now the whole run: `> call` lines for the export calls, and after each, the answers its imports got, in call order. `--save` writes it from a run, with a `?` where the run stopped; the agent replaces the `?` and runs again, taking the calls from the file. Replay is strict: a line that doesn't match the guest's call (another import, other arguments, an answer left over, or a missing call line) stops the run with an error naming the line to restart from. Happy path past the file's last answer, set by --defaults: - eager (default): `ok` for results, records and tuples of those, and `#mock` for handles, noting the instances it passed over, e.g. `// default; instances of y: #y-1 (from new imports/y(10))`; or the only instance of a resource that can't be mocked - careful: stops where there are instances to choose - none: stops at every import that asks careful and none require each answer to give its arguments. Self-contained files: - new mocks are written as `#mock-<id>`, e.g. `#mock-y-2`, and replay checks the mock gets that id - `//!` lines, rewritten on each save: a header (component, --defaults, what isn't recorded: JavaScript imports, WASI) and the origins of instances no answer returned, e.g. `//! new imports/y(10) = #y-1` - other comments are kept, including the notes on defaults Calls and values: - methods are written `iface/res.method(#borrow-x-1, …)` everywhere, in the UI console too; CallRecord gains `callee`, and a method's `self` is its first argument - a list<u8> of text shows as b"…", in the UI and CLI, and reads back - stop hints give the expected type, the named types it uses, and the instances the UI's instance picker would offer Output and exit codes: - the run is printed as the console shows it, from the last answer on (--from, --all), each answer marked with where it came from - --json is removed - exit 3 when a call fails, e.g. traps; 2 when an answer is needed
…essions Skill and CLI usability, from two test agents debugging resource.wasm and kv-store.wasm with only the skill: - skills/component-ui/SKILL.md: the run, fill the ?, rerun loop; reviewing defaults; reading runs and traps; WAVE and call syntax; the answers file; options; limits. Tells agents to ask for the original commented WIT, which components don't embed. - `component-ui template <component.wasm> <pkg>` prints the JavaScript module the UI's import editor starts with, for --imports. - inspect lists the exports, the host resources they use, the imports that ask, and those answered on their own; "Types: none" when empty. - A call that traps before using the answers after it is reported as the trap, with exit 3, rather than as a divergence. - Stop hints say where the call's instances came from; instance lists say how an instance became available, e.g. moved into the host. - An --imports function skipped because an argument is a mock says so on its line, in the UI and the CLI, and the CLI warns at the end. - The CLI build makes dist-cli/component-ui.mjs executable. One call log for the UI and the CLI (src/call-log.ts): - Calls, the imports made during them, stream output and created instances, in order. The console renders it; the CLI prints it and writes answers files from it. - ImportLog gains `answered()`, so any answer's new mocks, the UI's too, are written as #mock-<id>. - The answers file shows every import the guest made: answers, holes, and `//!` lines for those that didn't ask, e.g. constructor; and the guest's drops of host instances, `//! drop #y-2`, which the console now shows too. Saving sessions in the UI: - Save on the console writes the current instance's calls and answers as an answers file, which the CLI replays and co to calls are written as #mock-<id> (#borrow-mock-<id> for a borrow), and the CLI's call syntax gains `drop #x-1`. Trap stacks: - The guest's wasm frames show under a trap, in the console and the CLI, without its runtime's (e.g. Rust panic hand 10-frame limit. - crates/bindgen exports `demangle`, using rustc-d cpp_demangle (+62 KB gzipped). Shared instance descriptions (describeInstance, holdersText) for CLI hints, the resource table and the UI's instance pi gain a provenance tooltip. Docs: README (features, CLI, how it works, known limitations: imports can't ask from destructors, during instantiation, constructors and statics; embedded WIT lacks doc comments) and the gap doc (answers files from the UI, #mock-<id>, b"…").
The console can answer imports that ask as the CLI's --defaults does:
- "Ask for every result" (none, the default): every import shows a form.
- "Fill in obvious results" (careful): ok, and a new mock where nothing
else could be returned; the others ask.
- "Fill in, preferring new mocks" (eager): also a new mock where existing
instances could be returned.
A defaulted import is marked `// default` at the end of its line, and
Save writes it as the CLI's --save does, notes on passed-over instances
included, so the session replays in the CLI.
The console's header is down to a gear button. Its settings dialog has
the levels, with what each does, and Save logs and Clear logs, which
both close it. The choice is kept in localStorage.
Shared answering code, in src/answering.ts:
- happyValue, listInstances and the levels, moved out of the driver and
the answers file;
- promptContext(prompt): the instance picker's choices, and whether a
mock can be made;
- defaultAnswer(prompt, level) and answerPrompt(prompt, answer), which
the driver and the console both use.
Who answered an import is in the shared call log: ImportPrompt.answer
and trap take an optional `by` ({ source, note }), passed to the log's
`answered()`. The CLI's run output and the answers-file writer read it
from there; the CLI's own map of answers keeps only what's file-specific
(comments, and whether an answer gave its arguments).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch makes component-ui usable by agents. A command-line frontend runs a component
without its host, answers its imports from a file, and writes each run back as a file that
reproduces it. A skill teaches agents the workflow. To get there, the core is separated from
the UI and shared with the CLI, and values are read and written with proxy-component's WAVE
codec. The UI gains those features too: trap stacks, saving sessions, and answering imports
by default.
Command line (
component-ui)inspect app.wasmlists:functions without a result)
call app.wasm '<call>'… [--answers f] [--save f]runs calls on one instance. Calls arewritten as the console shows them:
iface.func(1, "a"),new iface/res(1),iface/res.method(#borrow-res-1, …),drop #res-1, with WAVE arguments.> calllines, then each import the guest made, in order:import(args) = value, or!trapto trap the guest= ?, where the run stopped//!line for an import that didn't ask, e.g.//! imports/y.set-a(#borrow-y-1, 20)or
//! drop #y-2#mock-<id>, so later uses of the id point back to where it wascreated, and replay checks the mock gets that id.
restart from.
--defaults none|careful|eager(defaulteager):okfor results, and records and tuples of those--savemarks these// default, for the agent to reviewfailed (e.g. trapped), 1 on errors.
! cannot block a synchronous task before returning
at kv_store_component.wasm: <fastly::http::response::handle::ResponseHandle as core::ops::drop::Drop>::drop
at kv_store_component.wasm: kv_store_component::…::kv_store::Entry::dtor
at kv_test-….wasm: kv_test::write_entry
--imports <pkg>=<file.js>implements imports in JavaScript, andtemplate app.wasm <pkg>prints a module to start from. A function skipped because anargument is a mock says so, on its line and in a final warning.
npm run build:clibuildsdist-cli/component-ui.mjs, abinthatnpm linkputs on thePATH. It needs Node 23 or later, and restarts itself with--experimental-wasm-jspiwhere needed.Agent skill (
skills/component-ui/SKILL.md)--save, fill in the?, rerun.mock.
err(…)or!trap.broke the host's contract, and that agents should ask for the original commented WIT, which
components don't embed.
resource.wasm'stest-imports()returnok. The other tracedkv-store.wasm's handling of a GET request,and found that it sends its KV lookup twice. Their friction is fixed here: trap reporting,
stack frames,
inspect's sections, a full trace in the file, instance provenance in hints,and skill clarifications.
UI
preferring new mocks", the CLI's three levels; defaulted answers are marked
// defaultnonerecord fields are omitted#borrow-x-1(was#borrowed-x-1)iface/res.method(#borrow-x-1, …)(was#borrowed-x-1.method(…))list<u8>of text shows asb"…"Core changes
src/platform*.ts,src/log.ts). In Node, the core uses thebrowser build of preview2-shim: its Node build exits the process on
wasi:cli/exit, leaksthe host's environment, and keeps global state.
src/call-record.ts): interface, resource, kind, function,WAVE arguments with a method's
selffirst, and a callee.src/call-log.ts): calls, imports, output and instancecreations, in order. The console renders it; the CLI prints it and writes answers files
from it.
crates/bindgen(format/parse).The UI and CLI write values exactly as proxy-component does. The parser checks handle labels
as strictly as proxy-component, and adds
#mock,#mock-<id>andb"…".demangleincrates/bindgen, withrustc-demangleandcpp_demangle. It adds 62 KBto the gzipped bindgen.
src/answering.ts): the happy path,promptContext,defaultAnswerand
answerPrompt, used by both the driver and the console.Docs
docs/proxy-component-gap.md: where component-ui and proxy-component differ (trace format,names, handles, coverage), what now matches (the value encoding), and how answers files
relate to proxy-component's traces.
constructors and statics, all because of where jco can suspend
wasi:cli/exitisn't reflected in the exit codeTesting
npm run test:node, 30 tests, covering:b"…",#mock-<id>--savereplays, divergences, defaults levels, traps, mocksin call arguments, drops, JavaScript imports
the defaults levels, and trap frames.
Node 22's V8 lacks
WebAssembly.Suspending.Not in this PR
the shared driver.
where guests use them.
from its JSPI trampolines.