A code workspace that an agent and a person share. Open a folder, and the buffers you have open, the folders you have expanded, and the version each buffer was read at are one record on the bus — so the file an agent opens appears in your tabs, and the file you open is one the agent can see.
The unit is a folder, not a repository. The tree, the tabs, the editor and the finder all work in a plain directory; git adds a branch label and change marks when the root happens to be a repo, and nothing else changes when it is not.
When an agent is working, its edits land in the changes tab as they
happen: one group per turn with the files it touched and the lines it moved,
and the diff of any one of them a click away. Nothing is polled — the worker
observes every filesystem call the agent makes and pushes an editor::changed
event, so a write made by anything shows up, including tools that never call
this worker.
It opens no files itself. Reads, writes, moves, listings and git all go
through the shell worker,
so shell's jail and denylist are the only filesystem boundary; the workspace
record lives in state.
What editor adds is the model on top: diffing, ranking paths, refusing a
stale write, and keeping open buffers correct when a folder moves under them.
iii worker add editor
iii worker add shell # required — editor has no filesystem access of its own
iii worker add state # required — the workspace record lives here| Worker | Why |
|---|---|
shell |
Required. Every read, write, move, listing (coder::tree) and git invocation. Its fs.host_roots jail governs which paths editor can reach. |
state |
Required. Holds the active root and one session per project (open buffers, expanded folders). |
console |
Optional. Renders the #/ext/editor page and the editor::* chat cards. |
use iii_sdk::protocol::TriggerRequest;
use iii_sdk::{register_worker, InitOptions};
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let iii = register_worker("ws://localhost:49134", InitOptions::default());
let call = |id: &str, payload| iii.trigger(TriggerRequest {
function_id: id.into(), payload, action: None, timeout_ms: Some(30_000),
});
// Any folder. No repository required.
call("editor::workspace::open", json!({ "root": "/srv/app" })).await?;
let file = call("editor::open", json!({ "path": "src/main.rs" })).await?;
let edited = file["content"].as_str().unwrap().replace("TODO", "done");
// Show the change before making it.
let preview = call("editor::diff", json!({
"before": file["content"], "after": edited, "path": "src/main.rs",
})).await?;
println!("{}", preview["patch"].as_str().unwrap());
// The version from the open is what makes this safe: if anything else
// touched the file in between, nothing is written and the divergence
// comes back as a patch. `expected_mtime` also works and is still
// honoured, but it cannot see a write that landed inside the same second.
let saved = call("editor::save", json!({
"path": "src/main.rs", "content": edited, "expected_version": file["version"],
})).await?;
if saved["conflict"] == true {
println!("refused:\n{}", saved["conflict_patch"].as_str().unwrap());
}
Ok(())
}| Function | Does |
|---|---|
editor::workspace::open |
Point the workspace at a folder. Returns the buffers and expanded folders remembered for it. |
editor::workspace::get |
The active root, open buffers, and expanded folders — what every surface sees. |
editor::tree |
List a folder, with the workspace's expansion state. The walk, the noise-folder excludes and the jail are shell's. |
editor::open |
Read a text file and record it as an open buffer, with its language id and the content version to save against. |
editor::save |
Whole-file write, refused when the file changed since the open it started from. The refusal carries the disk-vs-yours diff. |
editor::buffers::list |
Files currently open. |
editor::buffers::close |
Close one buffer. The file on disk is untouched. |
editor::move |
Move or rename, then rewrite every open buffer and expanded folder at or under the path. |
editor::create |
Create a file or folder, parents included. A file may be seeded with content. |
editor::delete |
Remove a path and close any buffer it held. |
editor::find |
Fuzzy file finder, ranked basename-first. Candidates from git in a repo, from the folder listing otherwise. |
editor::search |
Search file contents across the workspace, grouped by file. shell's recursive grep, shaped for a results panel. |
editor::diff |
Unified patch between two texts. Pure — no path is read, so it works on content that is not on disk yet. |
editor::git::status |
Branch, upstream, ahead/behind, and one typed row per changed path. |
editor::git::hunks |
What changed in one file: the rendered patch plus its line ranges. |
editor::git::show |
A file's contents at a revision, HEAD by default. Pair it with the working copy to render a diff without parsing a patch. |
editor::git::commit |
Stage and commit. committed: false when there was nothing staged. |
editor::git::sync |
Fetch, fast-forward pull, or push, with ahead/behind after. |
editor::git::stash |
Stash the working tree, or pop the most recent stash. |
editor::git::undo-commit |
reset --soft HEAD~1, returning the SHA and message undone. |
Pull is --ff-only on purpose: a merge under open buffers is how an editor
ends up showing a conflicted tree nobody asked for. A repository that needs
interactive credentials will hit git_timeout_ms rather than hang, because
shell::exec owns the process.
Two are worth calling out. editor::diff is the one an agent reaches for most:
it can show exactly what a write will change before making it. And editor::move
exists because shell::fs::mv alone leaves open buffers pointing at the old
path — the next save then writes them back there, silently recreating the folder
that was just moved.
| Trigger type | Fires when | Payload |
|---|---|---|
editor::changed |
A file in the workspace changed, whoever changed it | path, cause (the function id that did it), kind (created | modified | deleted | unknown), added, removed, patch, truncated, root |
The event is how a surface follows an agent without polling. It does not
require the agent to cooperate: the worker binds a harness::hook::post-trigger
hook on the shell::* and coder::* write paths, so an edit made by anything
becomes an event. The hook is advisory and fail-open — it never delays or
denies the write that produced it.
use iii_sdk::protocol::RegisterTriggerInput;
iii.register_trigger(RegisterTriggerInput {
trigger_type: "editor::changed".to_string(),
function_id: "my-worker::on-edit".to_string(),
config: serde_json::json!({}),
metadata: None,
})?;Bindings take no config. Delivery is fire-and-forget: a slow or absent
subscriber is logged and skipped. patch is capped at 16 KiB with truncated
set — ask editor::git::hunks when you need the whole thing.
#/ext/editor is a view over the same workspace: a collapsible file tree on
the left with a files/search switch, tabs on the right, and a save that
surfaces the conflict guard as a dialog. The open file has its own view strip
— read, edit, preview on a markdown file, unsaved while there is
something unsaved, and head for the diff against the last commit. A status
line under it carries the path, language, line count, git deltas, saved state
and the most recent observed edit. A git strip along the bottom does commit,
fetch, pull, push, stash and pop. Folder expansion round-trips through the
worker, so it survives a reload and both surfaces agree on it.
Nothing is polled: one read on mount seeds the git overlay, and after that the
page reacts to editor::changed and to the workspace's own state scope. So a
file an agent edits lights up as the edit lands and an open tab you have not
typed in reloads under you. A tab you have edited is never reloaded; it is
flagged, and the conflict guard decides the outcome.
editor::* calls also render as themselves in chat and traces: a diff as a
diff, a save as a file card with its line counts.
Files and diffs are drawn with @pierre/diffs
— real line numbers, a syntax theme, and its own add/delete colouring, all
inside its own shadow root. editor::diff and editor::git::hunks already
return unified patch text, which is exactly what it parses. Editing stays on
the console's shared Monaco CodeEditor: it is the one editing surface, and
the SOP forbids bundling a second editor to get chrome back. Bundling
@pierre/diffs as-is costs 10.3 MB — over the console's 8 MiB per-asset cap,
and almost all of it shiki's full grammar and theme catalogs — so ui/build.mjs
narrows those catalogs to what this worker opens and asserts both the size
budget and that highlighting still works.
Runtime config lives in the configuration worker under id editor, so the
console's Workers tab can edit it and every field hot-reloads — handlers read
the live snapshot per call, and nothing needs a restart. The block below is what
gets seeded when nothing is stored yet, and --config <path> takes a file in
that shape as an optional one-time seed that never overwrites a stored value.
The committed config.yaml is not that file: it is a bare engine config
(workers: []) for running this worker from source, and it would be rejected as
a worker seed.
If the configuration worker cannot be reached at boot, the worker retries, then
starts on the --config seed rather than exiting — with a warning naming which
numbers are in force — and keeps asking in the background until the
authoritative value lands. The built-in defaults are used only when nothing was
seeded either.
Every field is a bound. Nothing here grants access — that is shell's config.
max_diff_bytes: 2000000 # per side of editor::diff, bytes
diff_context_lines: 3 # editor::diff's default context; hunks defaults to 0
find_limit: 50 # rows returned by editor::find
max_find_candidates: 50000 # paths scanned per editor::find call
max_file_bytes: 2000000 # largest file editor::open will pull back
search_max_matches: 2000 # matching lines editor::search collects
git_timeout_ms: 15000 # per git invocation handed to shell::execiii -c config.yaml # a bare engine (workers: []) so this worker runs from source
cargo run --release -- --url ws://127.0.0.1:49134
cargo test--url also reads III_URL, which the worker manager injects — inside a
sandbox the engine is on the VM's gateway, never on its loopback.
The console assets are built from ui/ by build.rs. For the hot-reload loop:
cd ui && pnpm install && pnpm watch # esbuild --watch → dist/
III_EDITOR_UI_WATCH=1 cargo run # re-registers each changed asset