Updating the system interface
system/apps/system_interface is the live web UI the user is looking at right now
(the dockview shell, the sidebar, the New Tab launcher). A broken build here is
served straight to the user, so you never edit the served copy directly: you
make every change in an isolated worktree clone, verify it builds and passes
there, and only merge it back into the served tree once it's known-good. This
skill is the single canonical path for that.
This is the system-interface specialization of the generic creation
lifecycle. It reuses the generic update orchestration -- the task file, the
generic harden-worker, and the report poll -- from update-creation with
type=system-interface, and adds the one thing the system interface needs
that no other creation does: a pre-merge preview, then a go-live through
the general update apply (update_self.py apply), which lands the merge
and reveals it as one atomic, rollback-on-failure motion. The
worker/orchestration core is shared; the preview is owned here, and the apply
is shared with update-self.
The hard rule
Never edit the system-interface tree that is being served to the user. Do
not run Edit/Write on files under system/apps/system_interface/ in this (the
served) checkout, and do not rebuild or restart the live UI from uncommitted
edits here. Every change is made in a separate, isolated clone of the source,
built and tested there, and merged back only after it passes. The only things
you do to the served tree are running this skill's preview / unpreview
commands and the general apply (which lands the merge itself) -- and
preview/unpreview never modify the served tree at all (they only boot
throwaway servers against the worker's separate, already-built work_dir, so
even the pre-merge preview can't reach what the user is looking at).
That isolated clone is the generic harden worker -- its own git worktree and copy of the source, so a half-broken build can never reach the user. The worker is just the mechanism for that safe, separate place to work.
Flow overview
- Delegate the change to the generic worker via the
update-creationorchestration core (type=system-interface). The worker followsharden-creation.md+op-update.md+type-system-interface.md, which own how to build, test, and verify the change in isolation. - The worker implements + builds + tests it on its own branch, then reports
done(the system interface emits no gate -- approval is your preview). - You preview the change before merging: the worker is a local worktree-agent in this container that already built its own work_dir, so one command boots that folder and serves it -- wrapped in a labeled "preview" frame -- for the user to click around. The user approves or rejects.
- On approval, you run the general apply: one command that lands the merge and reveals it (refresh dependencies, install the worker's built bundle, restart the services agent, verify the live UI is healthy, auto-rollback on failure -- with an interruption marker so even a hard kill is recoverable), then tear down the preview. On rejection, you tear down the preview and hand back -- nothing is merged.
1-2. Delegate via the generic update orchestration
Check for a concurrent system-interface pass first. Passes here are named
per-slug, so two can coexist without colliding on names -- but both edit
system/apps/system_interface, so whichever merges second is guaranteed stale (see
the freshness check in Step 4). Look for another pass in flight:
grep -l 'type: system-interface' data/.tasks/harden/update-*/task.md
For any hit, check whether its update <slug> tracking ticket is still
open/in-progress in tk ready (and its worker alive, per lead-proxy.md's
liveness probe). If a live one exists, tell the user and let them decide --
dispatching in parallel is allowed but means the second merge will have to
rebase and re-verify. This is a warning, not a hard block.
Then follow update-creation Steps 1-3 (open a ticket, write the task file,
launch the worker, background-poll the report) with these system-interface
specifics:
- Pick a slug
$SLUGfor the change. The worker agent / branch isupdate-$SLUG/mngr/update-$SLUG; the runtime dir isdata/.tasks/harden/update-$SLUG/. - Task-file frontmatter:
operation: update,type: system-interface, plus the standardfinish_report_path(data/.tasks/harden/update-$SLUG/reports/report.md). Per the system-interface exception inop-update.md, there is no## Change originmarker -- the body is a plain change brief, not an absorb/verify incident. - Task-file body:
## What to do(the actual UI change the user asked for),## Context(which panel, desired behavior, constraints),## Real scenariowhen a real conversation motivates the change (the motivating agent id + what looks wrong in plain words -- see the next bullet; omit it, or write "no real scenario", for net-new work), and## Success criteria(what "done" looks like, plus the standing line: follow the installedharden-workersub-skill; it composesharden-creation.md,op-update.md, andtype-system-interface.mdfor how to run, test, verify, and what not to touch; reportdoneonly when its testing contract and the review gates all pass). - Judge whether a real scenario motivates the change, and if so point the
worker straight at it -- don't transcribe it. Most UI fixes come from how
something actually renders in a specific real conversation -- "this gap looks
wrong here", or "I don't like how permission requests render" said in a
conversation where a permission request happened (the discontent is with that
rendering, in that conversation). The worker is not cut off from that
conversation: the system interface discovers agents from the shared
MNGR_HOST_DIR, so an instance the worker boots in its own worktree renders the very same real conversations the user is looking at. So do not measure the DOM and write it into the brief -- that is a lossy game of telephone (you look at the real thing, transcribe it, and the worker reconstructs it from your prose, losing fidelity at each hop). Instead, just decide which case you are in and say so:- A real conversation motivated it (the usual case for a bug report): name
the motivating agent -- usually your own
$MNGR_AGENT_ID, or whichever agent the user was looking at when they complained -- in the## Real scenariosection, and describe in plain words what looks wrong about it. Tell the worker to open that conversation in its own instance and look at it before changing anything. The worker sees the real thing firsthand; it does not reconstruct it from your description. - No real scenario (e.g. a net-new control or layout with no precedent in any existing conversation): say so in the brief and let the worker build a representative fixture as usual. Don't manufacture a fake "real scenario." A change can be partly both -- anchored in a real conversation but adding something new -- in which case name the real anchor and call out the new part. Use your judgment.
- A real conversation motivated it (the usual case for a bug report): name
the motivating agent -- usually your own
- Launch with
--template worker(installs the genericharden-worker) perupdate-creationStep 3, then background-poll per.agents/shared/references/lead-proxy.md. - Terminal handling differs: the system interface emits no gate, and on
doneyou do not merge here (that isupdate-creationStep 4's behavior for other creations). Instead, go to the preview below. Onstuckor a dead-worker timeout, surface to the user per.agents/skills/launch-task/references/worker-failure.md-- do not preview, merge, or apply, and do not retry silently.
3. Preview the change before merging
On a terminal done, show the user the change before merging. The worker's
work_dir is a folder it has already built (its done contract runs
uv sync + npm run build). The preview just boots that folder -- no fetch, no
re-checkout, no rebuild. Do not destroy the worker yet -- the preview serves
its work_dir in place, so the worker must stay alive until the user gives a
verdict.
First resolve the worker's work_dir, then boot it:
WORK_DIR=$(mngr ls --include 'name == "update-<slug>"' --format json \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["agents"][0]["work_dir"])')
python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py preview \
--slug update-<slug> --work-dir "$WORK_DIR"
This boots the worker's already-built instance on a free port with layout
persistence neutered (it reads the same agents, so the user's real conversations
render, but it cannot clobber the live layout.json), then boots a small wrapper
page that embeds it in a labeled "preview" frame. The user-facing si-preview
service points at that wrapper (the inner instance is registered separately as
si-preview-app), so the tab reads as a clearly-marked proposed change. It does
not merge, touch the served tree, or modify the worker's folder. Exit 0
means the preview is up; a non-zero exit means it failed to boot (or the
work_dir was wrong / the worker was already destroyed) and tore itself down --
diagnose before retrying. One boot-refusal case is deliberate: the si-preview
tab can only show one pass at a time, so if another pass's preview is already
up, preview refuses rather than hijacking it -- surface that to the user and
coordinate with the other pass (its stderr says how to tear down an abandoned
one).
Open it as a tab and ask the user to explore. With no --view, the op goes
to the view the connected client is looking at:
python3 system/scripts/layout.py open si-preview
Self-verify against the real scenario before you ask the user. The preview's inner instance opens on its server's primary agent -- the worker's own (empty) agent, not the motivating conversation -- so the real case isn't on screen by default. If a real conversation motivated the change:
- Open the motivating conversation in the preview's inner app with Playwright
(
--no-sandbox): use the tab bar's add-tab (+) dropdown and pick the real agent (its.dockview-add-tab-dropdown-item), or otherwise navigate to it. - Look at it and confirm the change actually fixed the real case, comparing
it against what looked wrong in the original complaint. A worker reporting
donewith passing tests is not proof the real case is fixed -- you have the real conversation right in front of you, so confirm it with your own eyes. If it still looks wrong, the fix missed the real DOM: re-brief the worker rather than merging. - Tell the user how to see the real case themselves (the preview opens on the
worker's empty agent; they switch via the
+dropdown to the motivating agent).
Then confirm with the user: a binary keep/discard and room for free-form notes (what looks off, what they'd change). Wait for their answer before doing anything else.
4. On approval: take the lease and check freshness
If the user approves the preview:
Take the editing lease so no other chat's merge or apply interleaves with yours -- the apply's auto-rollback restores a captured revision, so a foreign merge landing mid-motion could be swept away by it. Same advisory mechanics as
update-app's "One editor at a time": first checktk readyfor another agent'sediting service system_interfacelease and surface it to the user instead of proceeding if one is held; thenLEASE_ID=$(tk create "editing service system_interface" -t chore \ -d "Held by $MNGR_AGENT_NAME across the apply; released after teardown.")and
tk start "$LEASE_ID"(as its own command). Unlike a live service edit, this lease deliberately spans the whole apply motion; it is released at the end of Step 5, never held across the wait for the user's preview verdict (that wait happens before this step).Freshness check -- the branch is only mergeable if
system/apps/system_interface/,system/apps/chat/frontend/, the sharedsystem/libs/workspace_ui/, and the npm lockfile (the trees the shell's and the chat's bundles are stamped over; the apply installs the worker's bundles only as a pair, so a stale chat stamp costs the shell's bundle too) have not changed since the worker branched (for example, another pass merged in the meantime):BASE=$(git merge-base HEAD "mngr/update-$SLUG") git diff --name-only "$BASE" HEAD -- system/apps/system_interface/ system/apps/chat/frontend/ system/libs/workspace_ui/ system/package-lock.jsonEmpty output means fresh: continue. Any output means the pass is stale -- do not apply, and never hand-resolve a conflicted merge (the principle in
.agents/shared/references/harden-contention.md). Release the lease, re-brief the worker to rebase onto the current tree and re-verify, and come back through preview once it reportsdoneagain.
If the user rejects, do not merge anything. Tear down the preview (see the end of the next section) and hand back with their feedback -- decide with them whether to re-brief the worker for another pass. Re-briefing is your judgment, not an automatic loop.
Note: the built static/ bundle is gitignored, so the merge brings only source
and dependency-manifest (pyproject.toml / package.json / lockfile) changes,
not the worker's build output. The apply installs the worker's already-built
bundle (the very build the user just previewed), falling back to a live build.
5. Apply the change, then tear down the preview
Run the general update apply -- the same script update-self lands releases
with -- pointing it at the worker's branch and its already-built bundle. Resolve
the work_dir again in the same invocation: Step 3's WORK_DIR is long gone,
because each bash call starts a fresh shell and the user's verdict sat between
them. A bundle path that does not resolve -- or one whose bundle was built from
some other frontend source than the merged tree's (the build stamps each
bundle with its source tree hash, and the apply checks it) -- is not an
error: the apply says so on stderr and falls back to a live build, losing only
the "what the user previewed is what ships" guarantee.
WORK_DIR=$(mngr ls --include 'name == "update-<slug>"' --format json \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["agents"][0]["work_dir"])')
python3 .agents/skills/update-self/scripts/update_self.py apply \
--merge-ref "mngr/update-$SLUG" \
--worker-bundle "system_interface=$WORK_DIR/system/apps/system_interface/imbue/system_interface/static" \
--worker-bundle "chat=$WORK_DIR/system/apps/chat/imbue/chat/static"
That single command owns the whole go-live as one deterministic, self-healing
motion -- you do not merge, or run npm/uv/mngr, by hand. It merges the
worker's branch (an ordinary merge; the rollback point is captured internally),
classifies what changed, snapshots the built bundle and the affected
environments, refreshes dependencies on a manifest change, installs the
worker's bundle (live build as fallback), pre-flights a backend change on a
throwaway port before touching the live service, restarts, verifies the live
UI to the frontend standard (scoped to a regression), refreshes every open
view, and auto-rolls-back the entire merge on any failure, restoring the
snapshots -- file copies that need neither npm nor a registry. A hard kill
mid-apply is recoverable too: the apply writes a marker under
data/.state/update-apply/, re-running the same apply resumes it, and the
boot/cron recover path rolls a stale one back on its own.
Interpret the exit code and report it to the user:
0-- applied; the live UI is updated and healthy. One variant to read for: if the workspace's frontend was already broken when the apply started and is still broken now, the change still lands and still exits0, but the final line names the breakage instead of confirming health. Pass that finding on -- it is a separate problem, not something rolling this change back would have fixed. (An apply that happened to fix it prints the ordinary healthy line.)2-- the change was bad and was automatically rolled back; the live UI is healthy on the previous revision, but the requested change did not land. Report this and diagnose before retrying -- the worker branch and its report are kept, so a diagnosed retry is a quick re-run of the same apply. This carries the same variant as0: when the frontend was already broken beforehand the rollback is never held to that standard, so the final line says the backend is healthy and names what could not be confirmed instead of claiming the UI is. Pass both problems on.3-- emergency: even rollback could not restore a healthy UI (including a rollback whose own git steps failed). The interface may be down; escalate immediately. The pre-apply copies are kept underdata/.state/update-apply/snapshots/, and when the apply touched the frontend the stderr names a copy per bundle (the shell's and the chat's) -- copying each back over its own served directory (system/apps/system_interface/imbue/system_interface/static/andsystem/apps/chat/imbue/chat/static/) needs neithernpmnor a registry, so pass both paths on with the escalation. Read the stderr rather than assuming a path is there. This exit also leaves a durabledata/.state/update-apply/emergency.json(reason, the agent that drove the apply, where the copies are) and the system interface shows a banner off it until it is gone, so deleting that file once the workspace is verified healthy is part of the repair -- see the update-self skill's exit-3 guidance.1-- precondition error; nothing was changed. A dirty tree, a merge conflict, another apply in flight, an interrupted apply of a different merge (runrecoverfirst, per the stderr), or this merge having already been landed and rolled back. A conflicted merge is aborted -- resolve it through a fresh worker pass, never by hand; and a rolled-back merge cannot be re-landed by re-running the apply (the rollback is a forward revert, so the merge stays in history while its content does not), so that one also needs a fresh worker pass off the currentHEAD.
Whenever Step 5 ends -- whatever its exit code: a successful apply, a rollback
(2), an emergency (3), a precondition refusal (1), or a rejection where
nothing was merged -- tear the preview down:
python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py unpreview --slug update-<slug>
unpreview is idempotent, so it is also the way to clean up after a preview
that failed partway.
unpreview only handles the service side; it does not touch the workspace
layout. The si-preview tab you opened earlier with layout.py open is a
separate concern (a layout panel, not a service), so you must close it yourself
-- otherwise the user is left with a stale tab pointing at a now-deregistered
service:
python3 system/scripts/layout.py close si-preview
Do this on every one of those exits, not only the successful one. Once the
preview is down and its tab is closed, the worker can be destroyed per
launch-task (after a failed apply, keep it until the diagnosis is done -- its
branch and report are the retry's input). Close the update-$SLUG ticket the
orchestration opened, and release the editing lease taken in Step 4 with
tk close "$LEASE_ID" "Apply finished." -- on every exit code, since a lease
left open blocks the next pass (on a rejection no lease was taken -- Step 4
never ran).
Why this exists as a script and not a checklist: if the backend fails to start, the user loses their entire chat UI -- there is nowhere left to surface an error message. The recover-or-revert logic must therefore run identically every time and can never be skipped -- even across a crash or a hard kill of the apply itself -- which is exactly what belongs in a deterministic script rather than agent prose.
system/scripts/layout.py refresh (the manage-layout skill) is unrelated -- it only
reloads a single inner iframe/panel for arranging the workspace, not the
top-level page, so it does not reveal a system-interface code change.
Why this shape
The UI is what the user is actively looking at, so the design goal is "never
serve a half-broken UI," not "iterate in place fast." The worker's isolated clone
(in-process testing, Playwright verification, review gates) makes it safe to
merge; the pre-merge preview lets the user actually click around the change
before anything lands -- and since the worker already built its own work_dir, the
preview just boots that folder in place rather than re-cloning or rebuilding; and
the general apply's pre-flight, health probe, autonomous rollback, and
interruption marker make it safe to go live in one motion. Preview setup and
teardown are deterministic, so they live as preview/unpreview sub-commands
of this skill's script rather than as agent prose -- the only non-deterministic
part, gating on the user's judgment, stays with you.