docs/internal/DEVCONTAINER_SPEC_GAP_PLAN.md
Companion to DEVCONTAINER_SPEC_GAP_ANALYSIS.md. That document
catalogs the gaps; this one lays out how to close them.
The plan is organized into pre-work (bugs uncovered during the gap
analysis) plus five phases (A–E). Each phase is independently
mergeable — a reviewer can ship A without committing to B, and so on.
Within a phase, work is broken into individual commits that each pass
cargo check --all-targets and cargo fmt on their own, per
CONTRIBUTING.md.
For every work item we record:
CONTRIBUTING.md,
every new user-facing flow gets an e2e test that drives
keyboard/mouse events and asserts on rendered output — never on
internal state. Bugs get a failing test first, then the fix.cargo test … write_fresh_dts_file or
./scripts/gen_schema.sh runs required when touching the plugin API
or config types.git log stays readable.CONTRIBUTING.mdThese shape the plan end-to-end; calling them out once so later sections can assume them:
FileSystem trait for all filesystem access. Anything that
reaches for .devcontainer/devcontainer.json, a log file, or a
workspace path must go through authority.filesystem, not
std::fs / std::path::Path::exists. The container's workspace
is bind-mounted so paths coincide on local authorities, but remote
SSH users would silently break without this discipline.ProcessSpawner for external commands. Authority-scoped
commands (LSPs, :term, plugin spawnProcess) must route through
the active spawner. Host-side plugin work (devcontainer up,
docker logs) is the one documented exception — it goes through
LocalProcessSpawner via spawnHostProcess even when the active
authority is a container, because the container may not exist yet
or may be about to be torn down (see AUTHORITY_DESIGN.md).#[derive(JsonSchema)] / #[derive(TS)] type changes. Each such
commit bundles the regenerated artifact.fix:-prefixed; phase commits introducing new surface are feat:.Out of scope (reiterated from the gap analysis):
AUTHORITY_DESIGN.md principles 2–4
and the "shrink the core" stance. Not recommended to close.forwardPorts + docker port output.Everything else from the gap analysis is in scope and covered below.
Three items surfaced while walking the existing implementation. They are small, independent, and should land before Phase A so the baseline is clean.
find_devcontainer_config bypasses the FileSystem traitWhy. The helper added in the Remote Indicator popup branch
(app/popup_dialogs.rs::find_devcontainer_config) uses
std::path::Path::exists() directly. That call reaches for
std::fs::metadata under the hood, bypassing
authority.filesystem. On SSH authorities it would probe the host
filesystem instead of the remote — silently wrong, exactly the failure
mode CONTRIBUTING.md guideline 4 exists to prevent.
Files.
crates/fresh-editor/src/app/popup_dialogs.rs — rewrite the helper
to call self.authority.filesystem.exists(&primary).Tests. Add a regression unit test in popup_dialogs.rs (or the
closest existing test module) that installs a mock filesystem
returning true for .devcontainer/devcontainer.json and asserts the
helper returns Some(path). Failing-first per the bug-fix rule.
Commit split. One commit, fix:-prefixed.
plugins/config-schema.json matches the generatorWhy. The Remote Indicator branch hand-edited
plugins/config-schema.json alongside the JsonSchema derive impl in
config.rs. Per CONTRIBUTING.md guideline 6, the JSON file is an
auto-generated artifact and must come from ./scripts/gen_schema.sh.
If the two diverge by so much as a whitespace diff, future contributors
will overwrite the hand edit on their next schema regen.
Files.
./scripts/gen_schema.sh.plugins/config-schema.json diff and commit the regenerated
file.plugins/schemas/theme.schema.json and
plugins/schemas/package.schema.json too — the script regenerates
all three and we don't want to leave unrelated drift behind.Tests. None — regeneration is mechanical. A CI check that diffs the artifact against a fresh regen would catch future drift; adding that check is out of scope for this pre-work but worth a follow-up issue.
Commit split. One commit, chore: or fix: depending on whether
the diff is semantic. Mark the generated files as such in the
message.
fresh.d.ts)Why. The Remote Indicator branch didn't touch the plugin API
surface — it added a core action and a status-bar element, neither of
which is plugin-facing. But the show_remote_indicator_menu action
will appear in Action::all_names() if we later wire it into the
keybinding editor list, and fresh.d.ts enumerates action names
through a #[derive(TS)] boundary. Running the regeneration command
now catches any accidental surface creep and keeps the artifact
honest before Phase B adds a real new op.
Files.
cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored.plugins/lib/fresh.d.ts only if the regen produced a real
diff; otherwise close out with a note in the PR description.Tests. The regen command is the test — it runs through the generator and diffs against the checked-in file.
Commit split. One commit, chore: prefix if any diff lands.
All three items land before starting Phase A. Collectively they establish: every devcontainer-adjacent filesystem probe is authority-routed (P-1), every generated artifact is current (P-2, P-3). Phases L and A–E can then add new files and types without inheriting drift.
While verifying the gap analysis, we found that LspHandle::spawn
(services/lsp/async_handler.rs:2603) calls
tokio::process::Command::new(command) directly and
services/lsp/mod.rs::command_exists calls which::which(...) —
both bypass the authority. That means when attached to a container,
LSP servers still run on the host, looking up binaries in the
host's $PATH. rust-analyzer can't see the project's sysroot
from outside the container, clangd misses the container's include
dirs, and any language that's only installed inside the container
simply fails with "binary not found." This is a correctness bug
relative to AUTHORITY_DESIGN.md principle 2 ("authority is the sole
router for 'where'"), not a UX gap.
Closing it without adopting the VS Code remote-host model (spec §5)
requires a new trait abstraction: ProcessSpawner is one-shot
(command/args/cwd → SpawnResult), which doesn't fit a stdin/stdout
LSP server. Phase L introduces the long-running equivalent and routes
LSP through it.
LongRunningSpawner on AuthorityWhy. One trait for every long-lived stdio process (LSP today; PTY-less tool agents tomorrow) so the container case stays a single implementation.
Files.
crates/fresh-editor/src/services/remote/spawner.rs — new trait:
#[async_trait]
pub trait LongRunningSpawner: Send + Sync {
async fn spawn_stdio(
&self,
command: &str,
args: &[String],
env: Vec<(String, String)>,
cwd: Option<&Path>,
) -> Result<StdioChild, SpawnError>;
async fn command_exists(&self, command: &str) -> bool;
}
StdioChild wraps tokio::process::Child but exposes only
stdin, stdout, stderr, id(), kill(), wait(). Wrapping
is important because the container variant spawns docker exec,
not the binary directly — the rest of the LSP code shouldn't care.crates/fresh-editor/src/services/authority/mod.rs — add
pub long_running_spawner: Arc<dyn LongRunningSpawner> to
Authority. Constructors (Authority::local, Authority::ssh,
Authority::from_plugin_payload) wire the right impl.Tests. Trait-object round-trip: spawn sh -c 'echo hello' via
each impl, assert the stdout line matches.
Regen. None — internal trait, not exposed via plugin API or JSON schema.
Commit split. One commit adding the trait + StdioChild +
Authority wiring. No LSP call-site changes yet.
LongRunningSpawner for Local and DockerWhy. L-1 adds the trait; L-2 provides the two concrete impls.
Files.
crates/fresh-editor/src/services/remote/spawner.rs — new
LocalLongRunningSpawner wrapping tokio::process::Command. Host
command_exists delegates to which::which as today.crates/fresh-editor/src/services/authority/docker_spawner.rs —
sibling DockerLongRunningSpawner that runs
docker exec -i -u <user> -w <workspace> <id> <command> <args>.
-i keeps stdin open for the LSP's JSON-RPC stream; stdout/stderr
pipe back through the docker CLI like any other child. For
command_exists, run docker exec <id> sh -c 'command -v <cmd>'
and read the exit code.command_exists to ssh … 'command -v'
and spawn_stdio to ssh … <cmd>. Ship container first; land SSH
in the same phase if low-risk, otherwise a follow-up.Tests. Unit test (with a fake docker shim on PATH) that
asserts the composed command line. #[ignore]-gated integration
test that requires a real Docker daemon; contributors with docker
installed run it locally.
Commit split. Two commits: local impl first (zero behavioral change because LSP still uses the direct path), then docker impl.
LspHandle::spawn through AuthorityWhy. L-1/L-2 are dead surface until LspHandle adopts the
trait. This is the correctness fix.
Files.
crates/fresh-editor/src/services/lsp/async_handler.rs:
LspHandle::spawn takes authority: &Authority as a parameter.
Replace Command::new(command) with
authority.long_running_spawner.spawn_stdio(command, args, env, cwd).await.stderr_file fd-redirect doesn't compose with
docker exec — switch to reading from StdioChild::stderr in a
tokio task that writes lines to the log file via
authority.filesystem.write (guideline 4; the log file itself
lives host-side in both the local and container case because
the log dir is host-configured).process_limits / cgroup application only makes sense for
host-spawned children. Add a spawned_locally: bool on
StdioChild and skip post_spawn.apply_to_child(...) when the
child is docker/ssh itself rather than the real LSP.crates/fresh-editor/src/services/lsp/mod.rs::command_exists —
take &Authority, forward to
authority.long_running_spawner.command_exists(...). Callers in
popup_dialogs.rs and lsp_status.rs pass self.authority().LspManager::force_spawn — plumb the authority down from Editor.Tests.
LongRunningSpawner that records the spawn call;
assert LspHandle::spawn passed the right command + args.docker shim that forks a stub LSP server emitting a
canned initialize response. Boot Fresh with a container
authority (constructed via test-only helper), open a .rs file,
semantic-wait on the LSP indicator transitioning to "ready";
assert the shim received docker exec -i <id> rust-analyzer-style
arguments.Regen. None.
Commit split. Three commits — each passes cargo check --all-targets on its own:
authority through force_spawn and LspHandle::spawn,
still using the local spawn impl (no behavior change).authority.long_running_spawner.command_exists to the authority variant.Why. When command_exists returns false inside the container,
the current "binary not in PATH" advisory is misleading — it says to
install on the host. Update the copy.
Files.
crates/fresh-editor/src/app/popup_dialogs.rs — check the active
authority when rendering the (binary not in PATH) row; if
container, reword to (not installed in container) and swap the
install hint to point at postCreateCommand in
devcontainer.json.locales/en.json — new strings.Tests. E2E: attach to the fake container shim, open a file whose LSP binary is absent, trigger the LSP popup, assert the rendered row says "not installed in container."
Commit split. One commit.
With L-1..L-4 merged: opening a Rust file while attached to a
container spawns rust-analyzer inside the container, sees the
project's real sysroot, and responds to textDocument/hover
requests as if the editor were running natively inside. The LSP log
panel still works because stderr is forwarded. The authority
contract is preserved — no core code branches on "is this a
container?"; everything goes through authority.long_running_spawner.
Phase L does not adopt the VS Code remote-host model. We run the
LSP server process inside the container and the editor UI on the
host, with JSON-RPC flowing through docker exec -i's stdio pipe.
Spec §5 stays out of scope for the same reason — we don't need a
second Fresh instance inside the container to get the correctness
property the user actually cares about (servers see the container's
filesystem).
spawnHostProcess
for docker logs; no change.AUTHORITY_DESIGN.md.devcontainer up on the host.Five low-risk plugin-side items that don't need new Rust surface, plus
one rollout item that makes the {remote} indicator visible by
default. Each ships as its own commit so the git log reads as a
checklist of spec-aligning fixes.
{remote} on migrationWhy. The {remote} status-bar element shipped on this branch is
opt-in — users only see it if they've listed "{remote}" in
status_bar.left. The gap-analysis conversation settled on
"backwards compatibility is not required; bump the version and inject
the element on migration." This closes the "invisible to existing
users" risk for every subsequent phase that drives UI through the
indicator.
Files.
crates/fresh-editor/src/config_io.rs:
pub const CURRENT_CONFIG_VERSION: u32 = 2; (currently 1).migrate_v1_to_v2(value) that:
"version": 2 on the root object.editor.status_bar.left is present and doesn't already
contain "{remote}", inserts it at index 0.editor.status_bar.left is absent (i.e. the user never
overrode the default), do nothing — the new Default
already contains "{remote}" (see below).if version < 2 { value = migrate_v1_to_v2(value)?; }
crates/fresh-editor/src/config.rs — change
default_status_bar_left() to put StatusBarElement::RemoteIndicator
at position 0:
fn default_status_bar_left() -> Vec<StatusBarElement> {
vec![
StatusBarElement::RemoteIndicator,
StatusBarElement::Filename,
StatusBarElement::Cursor,
StatusBarElement::Diagnostics,
StatusBarElement::CursorCount,
StatusBarElement::Messages,
]
}
crates/fresh-editor/plugins/config-schema.json — regenerated via
./scripts/gen_schema.sh to pick up the new default.Tests.
config_io.rs (alongside the existing
migrate_v0_to_v1 tests): given {"version": 1, "editor": {"status_bar": {"left": ["{filename}"]}}}, assert the migration
produces {"version": 2, "editor": {"status_bar": {"left": ["{remote}", "{filename}"]}}}.{"version": 1} with no status_bar field,
assert the migration bumps the version but leaves left absent
(defaults take over at resolve time).{"version": 1, "editor": {"status_bar": {"left": ["{remote}", "{filename}"]}}}, assert the migration
doesn't duplicate — the element stays at index 0 unchanged.~/.config/fresh/config.json with a v1 config whose
left lacks {remote}, launch Fresh, assert (a) the in-memory
config now has {remote} at position 0 and (b) the config file
was rewritten with version: 2. File rewrite behavior follows
whatever the existing v0→v1 migration does — re-use its pattern.Regen. ./scripts/gen_schema.sh must run — the default for
status_bar.left in config-schema.json changes, and
CONTRIBUTING.md guideline 6 requires the regenerated artifact to
ship with the code change.
Commit split. Three commits.
feat: default_status_bar_left now includes RemoteIndicator —
pure default change plus regenerated schema. Existing tests that
check default contents get updated in the same commit.feat(config): bump CURRENT_CONFIG_VERSION to 2 with migration injecting {remote} — the migration function + dispatch line +
unit tests.test(config): e2e coverage for v1→v2 on-disk migration — the
two e2e tests above. Separated because the e2e tests take longer
to run and are easier to review in isolation.Five plugin-side items follow. All changes live in
crates/fresh-editor/plugins/devcontainer.ts and
crates/fresh-editor/plugins/devcontainer.i18n.json.
initializeCommand on the host before devcontainer upWhy. Gap analysis §6. The spec defines initializeCommand as
running on the host before container creation; the plugin currently
lists it in the info panel but never invokes it. This is a correctness
bug, not a UX one.
Files.
crates/fresh-editor/plugins/devcontainer.ts — inside
runDevcontainerUp, add a step before the devcontainer CLI call
that reads config.initializeCommand, formats it per
formatLifecycleCommand, and runs it via editor.spawnHostProcess.
Abort the attach on non-zero exit with the existing
status.rebuild_failed branch.devcontainer_run_lifecycle to
include initializeCommand so the palette picker offers it too.Tests. E2E: create a fixture workspace with
.devcontainer/devcontainer.json whose initializeCommand writes a
sentinel file to the fixture's temp dir. Trigger attach, assert the
sentinel exists before the (mocked) devcontainer up invocation
completes. Mocking is via PATH-prepending a fake devcontainer
script written into the fixture — same pattern e2e tests use today for
git and LSPs.
Commit split. Two commits. First commit: add the lifecycle entry
to the runner picker (pure additive, no behavior change to attach).
Second commit: wire initializeCommand into the attach flow —
fix:-prefixed because it closes a spec-violation bug.
Why. Gap analysis §2. Plugin labels "Attach" / "Not now" don't match the spec's "Reopen in Container" / "Ignore". Low-risk copy change.
Files.
crates/fresh-editor/plugins/devcontainer.i18n.json — rename the
popup.attach_action_attach / popup.attach_action_dismiss strings
across every locale. Keep the keys; change the English values and
re-translate the others or fall back (rust-i18n falls back to en
when a key is missing).Tests. E2E: assert the rendered action popup contains "Reopen in Container". The existing attach-prompt e2e test (if absent, add one) already renders the popup; the assertion becomes a one-line change.
Commit split. One commit, feat: or refactor: — pure surface
rename.
Why. Gap analysis §1. Remote Indicator menu shows a disabled "No dev container config detected" row when local and no config exists. The spec's "Configure Dev Container" option implies a create-flow.
Files.
crates/fresh-editor/plugins/devcontainer.ts — new
devcontainer_scaffold_config handler that writes a minimal
template to .devcontainer/devcontainer.json via
editor.writeFile, then opens it. Template content is
{ "name": "<workspace>", "image": "mcr.microsoft.com/devcontainers/base:ubuntu" }
— deliberately conservative so it's obviously a starting point.Dev Container: Create Config.Action::PluginAction("devcontainer_scaffold_config"). This is the
only core change in Phase A; make it a separate commit.Tests. E2E: open a temp workspace without .devcontainer,
trigger the scaffold command, assert the file exists and is opened in
a buffer. Second e2e: click the Remote Indicator, assert the
scaffold row is present and actionable.
Commit split. Two commits. First: plugin-only scaffold handler +
palette command. Second: wire the row into the Remote Indicator popup
(touches app/popup_dialogs.rs).
Why. Gap analysis §1. Remote Indicator popup advertises "Show Container Info" but the spec calls out "Show Container Logs" separately — today there is no way to see the container's stdout.
Files.
crates/fresh-editor/plugins/devcontainer.ts — new
devcontainer_show_logs handler. Reads the active authority's
container id (via a new editor.getAuthority() op or by parsing
display_label — the latter avoids plugin API churn for now),
runs editor.spawnHostProcess("docker", ["logs", "--tail", "1000", id]), and writes the output into a virtual buffer
*Dev Container Logs*.Dev Container: Show Logs.Show Container Logs in
app/popup_dialogs.rs::show_remote_indicator_popup that dispatches
the plugin action (when attached to a container authority).Tests. E2E: with a fake docker shim in PATH that emits
scripted log content, trigger the command and assert the virtual
buffer contains the scripted lines.
Commit split. Two commits. First: plugin handler + palette
command. Second: core popup row. (Streaming comes later in Phase C —
this cut uses the existing buffered spawnHostProcess.)
Why. Gap analysis §7. forwardPorts is shown in the info panel
but there's no way to see what the running container actually exposes.
Files.
crates/fresh-editor/plugins/devcontainer.ts — extend the existing
devcontainer_show_ports handler to, when a container authority is
active, run docker port <id> via spawnHostProcess and merge the
output with the configured forwardPorts list in the prompt
suggestions.configured: tcp · runtime: <host-port> → <container-port> (or
configured only when not bound, or runtime only when Docker
exposes a port not in config).Tests. E2E with a fake docker shim: trigger the command, assert
the rendered prompt suggestions match the scripted merge.
Commit split. One commit. Scoped to
devcontainer_show_ports; doesn't touch other commands.
With A-1..A-5 merged: initializeCommand is honored, the attach
prompt reads per spec, the "Configure Dev Container" path works end
to end, container logs are one command away, and users can see which
configured ports are actually bound. Everything still uses the
buffered spawnHostProcess; no new plugin API surface, no state
machine, no indicator sub-states.
Phase A leaves the Remote Indicator with three states (Local, Connected, Disconnected). The spec also asks for Connecting/Building (§3, §4) and a visible failure state that surfaces Retry (§8). Phase B adds those, plus the plugin op that drives them.
RemoteIndicatorState::Connecting + FailedAttach variantsWhy. Gaps §3, §4, §8. The status bar currently has no way to say "an attach is in progress" or "the last attach failed"; both are reachable but indistinguishable from Local.
Files.
crates/fresh-editor/src/view/ui/status_bar.rs — add two variants
to RemoteIndicatorState:
Connecting { phase: ConnectingPhase, since: Instant }FailedAttach { last_error: String }
plus a new ConnectingPhase enum (Initialize, Build, Start,
PostInit) mapping to the spec's state machine.Connecting uses a Unicode spinner glyph that rotates
per-frame (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) plus the authority label.
FailedAttach uses the error palette and renders as
[Attach failed — click for options].ElementKind::RemoteIndicator(RemoteIndicatorState) already carries
the state through; expand the palette selector in element_style
to map the two new variants.Tests. Unit test: assert element_style returns a non-default
style for each new variant. E2E test: construct an editor with a
test-only API for setting the state directly (gated behind
#[cfg(test)] so it doesn't leak into the plugin surface), assert
the rendered status bar contains the spinner glyph or the "Attach
failed" text.
Regen. None — these variants live inside the view crate; no JsonSchema or TS types.
Commit split. One commit. New rendering branches are purely
additive and the default never triggers them, so
cargo check --all-targets passes trivially.
editor.setRemoteIndicatorState(payload)Why. B-1 adds the states to the view; B-2 gives the plugin a way to drive them. Without this op the spinner would never appear.
Files.
crates/fresh-core/src/api.rs — add a new PluginCommand variant:
SetRemoteIndicatorState {
state: RemoteIndicatorStatePayload,
}
RemoteIndicatorStatePayload is a tagged enum mirroring the
view variants but with serializable error strings. Derives:
Debug, Clone, Serialize, Deserialize, TS, JsonSchema.crates/fresh-editor/src/app/plugin_dispatch.rs — match the new
variant. Translate the payload into a RemoteIndicatorState and
store it on a new pending_remote_state: Option<...> field on
Editor.crates/fresh-editor/src/app/render.rs — read
editor.remote_state() (new accessor) alongside
connection_display_string(); if remote_state is Some, it
overrides the derived Local/Connected/Disconnected state for the
rendered {remote} element.crates/fresh-editor/plugins/lib/fresh.d.ts — regenerated (see
Regen below).Tests. Plugin-runtime unit test that sends a
SetRemoteIndicatorState command and asserts it round-trips through
fresh_core::api. E2E that loads a test plugin calling
editor.setRemoteIndicatorState({kind: "connecting", phase: "build"}) on a hook, waits for the next render (semantic wait on the
rendered spinner, not a timer), and asserts the status bar shows it.
Regen.
cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored
for fresh.d.ts../scripts/gen_schema.sh because the new payload derives
JsonSchema and surfaces in the config schema's $defs.Commit split. Two commits. First: Rust-side variant + dispatch +
render integration, with the regenerated fresh.d.ts and
config-schema.json bundled in (per CONTRIBUTING.md artifact
rules). Second: chore: commit that only re-runs the generators in
case the first commit's diff isn't byte-identical to a clean regen.
devcontainer.tsWhy. B-1 and B-2 are dead surface until the devcontainer plugin actually transitions through the states.
Files.
crates/fresh-editor/plugins/devcontainer.ts — modify
runDevcontainerUp:
connecting { phase: initialize } before
initializeCommand (wired in A-1).connecting { phase: build } before calling
devcontainer up.devcontainer up JSON; on success call setAuthority
(which restarts the editor — state is reset naturally).failed_attach { last_error: stderr }.devcontainer_retry_attach that re-runs
runDevcontainerUp. The Remote Indicator popup's FailedAttach
branch (below) points to this handler.Connecting marker (set by a previous instance before setAuthority
restarted the editor). If found and an authority is now active,
clear it. If found and no authority is active, the previous attach
presumably failed or was cancelled; transition to FailedAttach.Tests. E2E with a fake devcontainer CLI shim that exits with
status 1 and a scripted stderr: trigger attach, semantic-wait on the
status bar reaching "Attach failed". Second e2e: fake CLI with a
long sleep and success JSON, semantic-wait on the spinner glyph
appearing and the indicator then transitioning to Connected once
the shim completes.
Commit split. Two commits. First: forward-path state transitions
(happy-path Connecting → restart → Connected). Second: failure path
(FailedAttach + retry handler).
Why. The popup's context-aware rows must reflect the new states. Connecting should offer "Show Logs" + "Cancel Startup" (the latter hooks into Phase C); FailedAttach should offer "Retry" + "Reopen Locally" + "Show Build Logs".
Files.
crates/fresh-editor/src/app/popup_dialogs.rs — extend
show_remote_indicator_popup with branches for the two new
variants:
Connecting rows: "Show Logs" (→
plugin:devcontainer_show_build_logs, wired in Phase D) and
"Cancel Startup" (→ plugin:devcontainer_cancel_attach, wired
in Phase C). Until those plugin handlers exist the rows are
disabled() with a (coming soon) suffix — never broken.FailedAttach rows: "Retry" (→
plugin:devcontainer_retry_attach), "Reopen Locally" (→
detach, already handled), "Show Build Logs" (→ same Phase D
handler).Tests. E2E driving the editor into each state and asserting the popup contents.
Commit split. One commit, feat:-prefixed.
With B-1..B-4 merged: the Remote Indicator visibly spins during attach, the status bar flips to an error palette on failure, and the popup's rows match the state. Phase C fills in "Cancel Startup" and Phase D fills in "Show Build Logs" — both currently render as disabled rows that clearly communicate the feature is coming.
Phases A/B visualize attach lifecycle but still rely on devcontainer up running to completion with output buffered in memory. Phase C
introduces line-streamed host-process execution and the kill-handle
plumbing that "Cancel Startup" needs. This is the largest plugin-API
change in the plan; it's gated so every piece is independently
testable.
SpawnHostProcessStreamingWhy. Gap analysis §4. The current SpawnHostProcess returns a
completed {stdout, stderr, exit_code}; there is no way to see
output as it arrives, and no handle to cancel.
Files.
crates/fresh-core/src/api.rs:
PluginCommand::SpawnHostProcessStreaming { command, args, cwd, process_id, callback_id }. process_id is caller-chosen
(TS side allocates it) so the TS Promise wrapping the handle can
correlate kill requests without waiting for a round trip.AsyncMessage::PluginProcessStreamLine { process_id, line, stream: StdStream } where StdStream is Stdout | Stderr.PluginProcessOutput { process_id, stdout, stderr, exit_code } as the terminal event — stdout/stderr left empty when
streaming.crates/fresh-editor/src/app/plugin_dispatch.rs:
LocalProcessSpawner
(host-side, per AUTHORITY_DESIGN.md), drive stdout/stderr with
tokio::io::BufReader::lines and forward each line to the
async-bridge sender.tokio::process::Child handle in a new
host_process_handles: HashMap<u64, tokio::process::Child> on
Editor so a subsequent kill command can find it.crates/fresh-editor/plugins/lib/fresh.d.ts — regenerated.crates/fresh-editor/plugins/lib/fresh.ts (or wherever the TS
surface shim lives) — implement spawnHostProcessStreaming(command, args, cwd?) returning
{ processId, onStdout, onStderr, wait, kill }. Under the hood
the function registers onStdout / onStderr callbacks keyed by
processId, then issues the plugin command.Tests. Unit test that serializes/deserializes every new
variant. Integration test that spawns sh -c 'for i in 1 2 3; do echo $i; sleep 0.05; done' through the new API and asserts the three
lines arrive before the exit event. Use semantic-wait on the exit
event, not a timer.
Regen. cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored and ./scripts/gen_schema.sh.
Commit split. Two commits. First: Rust-side variants + dispatch +
child-handle storage. Second: TS surface (fresh.d.ts regen +
fresh.ts shim).
KillHostProcessWhy. Pairs with C-1. Cancel Startup in the Remote Indicator
popup needs a way to actually stop devcontainer up.
Files.
crates/fresh-core/src/api.rs — new
PluginCommand::KillHostProcess { process_id }.crates/fresh-editor/src/app/plugin_dispatch.rs — look up the
handle from host_process_handles, call Child::kill(), remove
from the map. Gracefully no-op (with tracing::debug!) when the
id is unknown — the handle may have already exited.crates/fresh-editor/plugins/lib/fresh.ts — the kill() method on
the returned handle object calls this command with the stored
processId.Tests. Integration test: spawn sh -c 'sleep 10' streaming, call
kill(), semantic-wait on exit event. Assert the exit event arrived
with a non-zero exit_code (signal termination convention is -1 in
SpawnResult).
Regen. As above.
Commit split. One commit. Kill is the minimum viable surface; timeouts and signal-choice knobs (SIGTERM vs SIGKILL, graceful kill with timeout) can come later if asked.
runDevcontainerUp to streamWhy. C-1 and C-2 are dead surface until the devcontainer plugin adopts them.
Files.
crates/fresh-editor/plugins/devcontainer.ts:
await editor.spawnHostProcess("devcontainer", args) with spawnHostProcessStreaming. Collect stdout into a
buffer for the final JSON parse (the JSON line is emitted on
stdout at the end; streaming doesn't change that).onBuildLine(line, stream) callback — currently dumps to editor.debug; Phase D
replaces this with a write to the build-log virtual buffer.processId in a module-level
attachInFlight: ProcessHandle | null so
devcontainer_cancel_attach can call attachInFlight?.kill().devcontainer_cancel_attach that calls .kill() on
the in-flight handle, then sets
RemoteIndicatorState::Local (cancelled is not a failure, it's
a user-initiated revert).Tests. E2E with the existing fake-CLI shim extended to sleep
before emitting JSON. Trigger attach, semantic-wait on
Connecting, open the popup, confirm the "Cancel Startup" row,
semantic-wait on the indicator returning to Local. Assert the fake
shim actually received a termination (the shim can write a sentinel
file in its signal handler).
Commit split. Two commits. First: adopt streaming for the
happy-path (no cancellation yet). Second: cancellation handler +
attachInFlight tracking.
Why. Phase B's B-4 stubbed the row as disabled; now we can enable it.
Files.
crates/fresh-editor/src/app/popup_dialogs.rs — drop the
(coming soon) suffix and the disabled() call for the row.Tests. Reuse the C-3 e2e but assert the popup row is actionable
(no [dim] overlay in the rendered output — the closest proxy for
disabled in terminal tests).
Commit split. One commit.
With C-1..C-4 merged: devcontainer up output streams to debug
(Phase D makes it user-visible), the user can cancel an in-flight
attach from the Remote Indicator menu, and the plugin API has a
reusable streaming-spawn/kill primitive that future plugins can use.
No other core surface has changed; DockerExecSpawner and the
authority contract are untouched.
Phase C streams lines but sends them to editor.debug. Phase D makes
those lines user-visible in a dedicated buffer and closes the loop on
the "Show Build Logs" / "Retry" popup rows that Phase B stubbed.
*Dev Container Output* virtual bufferWhy. Gap analysis §4. The spec wants a "dedicated 'Dev Container
Output' terminal" that streams stdout/stderr live. Fresh already
supports virtual buffers via editor.createVirtualBufferInSplit —
reusing that avoids introducing a new buffer flavor.
Files.
crates/fresh-editor/plugins/devcontainer.ts:
buildLogBufferId: number | null. Lazily create
the virtual buffer the first time a build line arrives after an
attach starts. Close it on successful attach (it will be recreated
next time) but keep it open on failure so "Show Build Logs"
is not an empty tab.onBuildLine stub with appendToBuildLog(line, stream): read current content, append
line + "\n" (prefixed with stderr: for the stderr stream to
keep interleaving readable), write back. Virtual-buffer
setVirtualBufferContent is the existing API; if it doesn't
support efficient append, add a new appendVirtualBuffer(id, text) plugin command alongside (see D-2).devcontainer_show_build_logs — opens the buffer in a
split (focusing it if already visible). Uses the same
createVirtualBufferInSplit pattern as the info panel.Tests. E2E with the streaming fake CLI from Phase C: trigger attach, semantic-wait on the spinner, trigger "Show Build Logs" from the popup, assert the rendered buffer contains the scripted lines.
Commit split. One commit, feat:-prefixed.
AppendVirtualBuffer plugin commandWhy. If profiling D-1 shows setVirtualBufferContent rewrites the
whole buffer per line — which it likely does given the Rope storage —
high-volume builds (cargo build emitting thousands of lines) will
cause an O(n²) slowdown.
Decision gate. Run D-1 with a build that emits ~1000 lines and measure; only add D-2 if the profile shows quadratic behavior. Log the measurement in the PR description either way.
Files (if needed).
crates/fresh-core/src/api.rs — new
PluginCommand::AppendVirtualBuffer { buffer_id, text }.crates/fresh-editor/src/app/plugin_commands.rs — handle via an
insert at the buffer's end rather than a full-content replacement.crates/fresh-editor/plugins/lib/fresh.ts —
editor.appendVirtualBuffer(id, text).Tests. Microbenchmark in the unit-test layer: append 10k short
lines and assert total time is under some budget. Standard
CONTRIBUTING testing rule: no fixed timeout — the test uses a
Duration comparison to a generous budget that's still strictly
sub-quadratic (e.g. 2s for 10k lines).
Regen. fresh.d.ts + config-schema.json.
Commit split. One commit. Preceded by the profile data in the PR description.
Why. Phase B stubbed "Show Logs" (Connecting) and "Show Build
Logs" (FailedAttach) as disabled rows; both should now dispatch
plugin:devcontainer_show_build_logs.
Files.
crates/fresh-editor/src/app/popup_dialogs.rs — drop the
disabled() and (coming soon) from those rows; they're now
actionable.Tests. Extend the Phase B e2e tests that assert the popup contents for each state.
Commit split. One commit.
Why. Gap analysis §8. Spec calls for a notification on build failure with Retry / Reopen Locally; we want both the Remote Indicator popup path (already covered by Phase B's FailedAttach branch) and a proactive action popup so the user doesn't have to go hunting for the indicator.
Files.
crates/fresh-editor/plugins/devcontainer.ts — after setting
FailedAttach, show an action popup via
editor.showActionPopup({...}) with:
Retry → devcontainer_retry_attachShow Build Logs → devcontainer_show_build_logsReopen Locally → already-Local no-op but shown for symmetryDismissFailedAttach indicator in place (the user may want to retry
later from the popup menu).Tests. E2E with the failing fake CLI: trigger attach, semantic- wait on the "Attach failed" action popup, select "Show Build Logs", assert the build-log buffer is focused.
Commit split. One commit.
With D-1..D-4 merged: build output is live-visible in a dedicated buffer, failure surfaces a user-prompted Retry/Show Logs popup, and the Remote Indicator popup's previously-stubbed rows all dispatch to real handlers. The §4 and §8 gaps are fully closed. The only remaining spec items are §7 (customizations + ports), which Phase E picks up.
The final phase covers what §7 ("Ready State") asks of a container- active editor beyond launching shells at the right cwd. Two separate concerns land here; they are sequenced but do not depend on each other.
customizations.fresh.plugins namespaceWhy. Gap analysis §7. The spec calls out
customizations.vscode.extensions; VS Code extensions don't apply to
Fresh (different plugin model), but the shape of the feature is
worth mirroring under a Fresh-specific namespace.
Design. The plugin reads
config.customizations?.fresh?.plugins as string[]. Each entry is
a plugin file path relative to the workspace root, or a built-in
plugin name. After attach, the devcontainer plugin iterates and
invokes editor.loadPlugin(path) for each. Paths go through
authority.filesystem so they resolve inside the container on the
container authority (where the plugin files presumably live).
Files.
crates/fresh-editor/plugins/devcontainer.ts:
plugins_loaded when the active authority carries a
Container: label, read config.customizations?.fresh?.plugins
and call editor.loadPlugin(path) per entry.editor.debug
message — this avoids double-load after a reconnect.editor.loadPlugin already exists
per the existing pattern used by the init-script loader.Tests. E2E: fixture workspace with
.devcontainer/devcontainer.json containing
customizations.fresh.plugins = ["./my-test-plugin.ts"], and a
sibling my-test-plugin.ts that registers a command with a
distinctive name. Trigger attach (with the fake CLI that succeeds),
semantic-wait on the new command appearing in the palette.
Commit split. One commit.
Why. customizations.fresh.* is now a supported extension point;
plugin authors need to discover it.
Files.
docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md — add a
"Customizations" section describing the fresh.plugins array,
noting that paths resolve through the container's filesystem and
listing what vscode.extensions does not do (install VS Code
extensions).README.md for the devcontainer plugin (if one exists; otherwise
skip).Commit split. One commit, docs:-prefixed.
Why. Gap analysis §7. Phase A's A-5 merges configured
forwardPorts with docker port <id> output in the palette picker;
E-3 extends that to a standalone status view.
Files.
crates/fresh-editor/plugins/devcontainer.ts:
devcontainer_show_forwarded_ports_panel that opens
a virtual buffer *Dev Container Ports* tabulating:
forwardPorts entries (configured)portsAttributes labels / protocols / onAutoForward policydocker port <id> binding outputConfigured | Protocol | Label | Runtime binding.r keybinding within the panel, following the
info-panel button-row pattern already in devcontainer.ts).Dev Container: Show Forwarded Ports
(distinct from the picker-style Show Ports already registered —
this one opens the full panel).Tests. E2E: attach via fake CLI, trigger the panel, assert rows
for each configured port with the expected runtime binding string
(the fake docker port shim scripts the output).
Commit split. One commit.
The spec asks to "Detect any ports opened by the containerized
application and offer to forward them to localhost." Per the gap
analysis scope boundary, Fresh does not watch container-side
listeners. Users see what's configured and what Docker actually bound
via E-3; anything further would require a docker exec <id> ss -tln
loop that the terminal editor shouldn't run unbidden.
If demand for this appears post-launch, the cleanest add-on would be
a one-off palette command Dev Container: Scan for Listening Ports
that runs ss -tln inside the container and offers to add each new
entry to the panel — triggered, not continuous.
With E-1..E-3 merged: customizations.fresh.plugins is a documented
extension point, port forwarding has a dedicated live panel, and
Phase A's picker still works for quick lookups. All §7 spec items
within the declared scope are implemented.
These are not blockers but decisions that should land before the first commit of the phase they gate. Recorded here so the reviewer and implementer can align up front.
initializeCommand run direction for SSH authoritiesThe spec says initializeCommand runs on the host. Fresh's
spawnHostProcess currently means "local machine" regardless of
authority — correct for plain local attach, wrong for an SSH
authority that discovered a devcontainer.json on the remote and
wants to attach to a container on that same remote. The first
version (Phase A) should just run it via spawnHostProcess
(host-local) and accept the SSH-nested-container edge case as a
known limitation; revisit if we ever grow that workflow.
setRemoteIndicatorState be idempotent-within-a-tick?If the plugin calls setRemoteIndicatorState three times in one
event loop tick, the first two are overwritten before any render.
This is fine functionally but means test harness semantic waits on
intermediate states can miss them. Options:
Recommendation: option 1. Tests that need to observe intermediate
states should drive timing with editor.awaitRenderTick() (a plugin
API we don't have yet — if this recommendation doesn't hold up, add
that op rather than complicate the state machine).
Phase B-3 says "on plugin load, check for a pending Connecting
marker; if found and no authority is active, transition to
FailedAttach." But the marker could also survive a deliberate crash,
an OOM kill, a SIGKILL from the user. How long should a stale
marker linger before we assume it's unreachable?
Proposal: timestamp the marker; if older than 30 minutes on load, quietly clear without surfacing FailedAttach. 30 minutes is a guess — adjust based on feedback.
Phase C-1 assumes line-delimited events. devcontainer up emits
JSON, which is one line at the end but may include progress updates
on earlier lines that are reasonable to surface. If a plugin wants
raw bytes (e.g. a future LSP log streamer), this API won't serve it.
Decision: line-delimited is fine for every current use case and is
the shape every existing log-streaming API in terminal editors uses.
If raw-byte arrives as a need, add a second variant
SpawnHostProcessStreamingRaw alongside — don't change C-1's
shape.
Child::kill() sends SIGKILL on Unix. devcontainer up may own
child docker processes that SIGKILL leaks. We can address this
later with process-group handling; the initial Phase C cut accepts
possibly-leaked children, with a log line noting the limitation.
Fresh already ships an ANSI→style parser at
crates/fresh-editor/src/primitives/ansi.rs (AnsiParser →
ratatui::style::Style), used by the rendering pipeline for terminal
output. Phase D reuses this directly: the build-log virtual buffer
parses each incoming line with AnsiParser and emits styled overlay
ranges for each styled span. No new infrastructure required; the
"strip ANSI" fallback is off the table.
Implementation shape for D-1: the plugin-side append sends raw bytes
through; core-side AppendVirtualBuffer (D-2 if it lands) runs the
line through AnsiParser and stores each styled fragment as an
overlay alongside the text — same pattern the terminal buffer uses.
If D-2 doesn't land, the plugin-side rewrite call does the parse
before writing the buffer content.
customizations.fresh.pluginsIf a plugin listed in customizations depends on an npm package
installed by onCreateCommand, the plugin file may reference symbols
that don't exist until the lifecycle hook has run. Phase E-1 runs
plugin loads after plugins_loaded, which fires after authority
install — but the first run through the state machine may load them
before onCreateCommand output is observable. Any load failure
becomes a silent log line today.
Proposal: surface load failures via the same action-popup mechanism as attach failures. Deferred to a follow-up PR post-E-3.
| Phase | Scope | Gap items closed | New API | Lines of Rust added (est.) |
|---|---|---|---|---|
| Pre | 3 cleanups | — | none | ~20 |
| L | LSP-in-container via authority | bug beyond spec | internal LongRunningSpawner trait | ~400 |
| A | Spec alignments + config v2 rollout | §1 (partial), §2, §6, §7 (partial); rollout | none | ~80 (scaffold wiring + migration) |
| B | Indicator state machine | §3, §4 (visibility), §8 (display) | setRemoteIndicatorState | ~200 |
| C | Streaming host spawn + cancel | §4 (logs/cancel) | spawnHostProcessStreaming, killHostProcess | ~250 |
| D | Build-log buffer + retry popup | §4 (logs shown), §8 (retry) | appendVirtualBuffer (conditional) | ~50 |
| E | Customizations + ports panel | §7 | none | ~0 (pure plugin) |
Each row's estimate is a rough ballpark for review-planning; actual numbers emerge from the PRs.
Pre-work + Phase L + five lettered phases close every in-scope spec
gap identified in DEVCONTAINER_SPEC_GAP_ANALYSIS.md plus the
LSP-in-container correctness bug surfaced while planning. The plan
respects Fresh's architectural principles (authority opacity,
one-slot transition, plugin-owned backend lifecycle) and lines up
with CONTRIBUTING.md commit, test, and regeneration discipline
throughout. Each phase can stand on its own: if Phase C is cancelled
after Phase B, the Remote Indicator still works as a visible-but-
non-cancellable state machine; if Phase D is cancelled after Phase
C, logs stream to editor.debug rather than a buffer. Phase L is
the one phase that's not user-facing on its own — its value is
unlocked by any subsequent phase that uses LSP inside a container,
which is the common case for serious devcontainer users.