Scope: the six areas requested (SSE endpoint, /proc resource sampling, tmux helpers +
OpenCode agent command, OpenCode harness profile, bootstrap bash, TUI RES display).
Read-only review; no source files modified.
Verification done: cargo test (116 passed, 1 ignored), live opencode --help /
opencode run --help (v1.18.25, the version installed on this machine), and a
live opencode serve run to check the process comm name.
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 4 |
| Medium | 5 |
| Low | 10 |
| Nit | 5 |
Top 3 issues
src/tmux.rs:1046-1056— the OpenCode launch passes the initial prompt as a positional argument, but opencode's TUI positional is the project directory (opencode [project], confirmed viaopencode --help). Sessions created with a prompt — the primary task-creation flow — get a command likeopencode 'Fix the login bug', which opencode interprets as a path. The correct flag is--prompt(present in v1.18.25).src/tmux.rs:1046-1056— the OpenCode branch silently drops thesystem_prompt(the Showrunner session-context briefing) entirely, and launches without the auto-approve flag (--auto) that the other three agents all get (--dangerously-skip-permissions/ yolo /--approve). Result: opencode sessions run un-briefed and stall on every permission dialog.helpers/alert_status.sh:5-7— the tmux status-bar badge readsstatus.cache, but nothing in the current flow writes that file: the comment claims the Rust daemon writes it (it doesn't), and the legacyalerts.shdaemon that does write it is never started bysessionizer.tmux. The badge permanently displays0.
- Where:
src/tmux.rs:1051-1054(AgentKind::OpenCodebranch ofbuild_agent_command) - What:
cmd.push_str(&shell_escape(prompt))appends the prompt as a bare positional.opencode --help(v1.18.25, verified on this machine) documents the default TUI command asopencode [project]withPositionals: project — path to start opencode in. There is no message positional for the TUI. - Why it matters:
create_task_session/create_sessionpassbuild_initial_prompt(...)(startup skills + user task,src/tmux.rs:393), so the common "new task with prompt" flow generatesopencode 'Run these startup skills... Task: ...'— opencode treats that string as a directory and the session fails to start (or starts in the wrong project). Shell quoting is correct (shell_escape+ tmux's own arg tokenizer), so this is purely a flag mistake. - Fix: use the TUI's prompt flag:
opencode --auto [--continue] [--prompt <escaped>](--prompt "prompt to use"exists in the TUI options;opencode run [message..]is the non-interactive alternative but exits when done, so it is the wrong shape for a tmux pane).
- Where:
src/tmux.rs:1046-1056 - What: The function contract (
src/tmux.rs:972-976) is "agents without a system-prompt flag get it prepended to the first message instead" — Claude/Pi use--append-system-prompt, Codex merges it into the positional prompt when!resume. The OpenCode branch only looks atinitial_prompt;system_prompt(the project/branch/PR/skills briefing built atsrc/tmux.rs:1211-1247) is never delivered to opencode. - Why it matters: opencode sessions run with none of the Showrunner context (task branch rules, "merge into task branch, never push the worktree branch", the manage-sessions skill pointer) — silently, with no error.
- Fix: mirror the Codex branch: when
!resume, combineSome(sp)+Some(p) → "{sp}\n\n{p}",Some(sp)+None → sp, and pass it via--prompt.
- Where:
src/tmux.rs:1047(let mut cmd = String::from("opencode");) - What: Claude gets
--dangerously-skip-permissions, Codex--dangerously-bypass-approvals-and-sandbox, Pi--approve. OpenCode gets nothing.opencode --helpshows the equivalent:--auto — auto-approve permissions that are not explicitly denied (dangerous!). - Why it matters: with default permissions, opencode prompts on bash/edit
calls, so every session stalls in the tmux pane while the TUI shows
"WaitingForPermission" — the exact failure mode the other agents were
configured to avoid. The whole harness design (attention detection,
askflow) assumes yolo-style launch. - Fix:
let mut cmd = String::from("opencode --auto");
- Where:
helpers/alert_status.sh:5-7(comment),:10-18(read); writer missing repo-wide - What: The script reads
$CACHE_DIR/status.cacheand its comment says it is "a single integer written by the showrunner Rust daemon". No Rust code writesstatus.cache(grep acrosssrc/: zero hits). The only writer ishelpers/alerts.sh:172-175(alert_update_indicators), but thealerts.shdaemon (alert_daemon_start) is never invoked by anything:sessionizer.tmuxonly starts the Rust daemon (scripts/daemon.sh), andscripts/main.sh(which doesn't sourcealerts.sheither) is no longer wired into any binding. - Why it matters: the
#(…/alert_status.sh)badge instatus-rightpermanently shows⚠ 0, and the comment actively misdirects future debugging toward the Rust daemon. - Fix: pick one owner: (a) have the Rust daemon write the count of
WaitingForPermission/WaitingForInputsessions tostatus.cacheeach tick (it already computes these), or (b) start thealerts.shdaemon fromsessionizer.tmux, or (c) delete the badge until a writer exists. Update the comment to match whichever is chosen.
- Where:
src/server.rs:225-246(clone),:221(interval),:253(KeepAlive) - What:
api_eventsdoesstate.worker.latest.lock().unwrap().clone()— it never consumes the update (contrastapi_state:182which uses.take()). The worker republisheslatestonce or twice per tick (~1–3 s), but the SSE loop wakes every 500 ms and, finding the sameSome(u), rebuilds and re-sends the identical full state JSON every 500 ms. Each emission also does aConfig::load()disk read +build_stateon aspawn_blockingthread. Additionally,tokio::time::intervaldefaults toMissedTickBehavior::Burst, so a client that stalls (slow network) gets a burst of duplicate state events on resume. - Why it matters: 2–4× redundant bandwidth/CPU per client, scaling linearly
with client count — this is the mobile-web path over tailnet. Latent today
(the in-repo
src/web/app.jsonly polls/api/state; noEventSourcefor/eventsexists in the repo), but the route is public and the next client will inherit the behavior. - Fix: only emit on change — e.g. have the worker bump a
generation: u64in the update (or store(generation, WorkerUpdate)), have the SSE loop track the last emitted generation, and skip when unchanged. Alsotimer.set_missed_tick_behavior(MissedTickBehavior::Skip)(stable in tokio 1.50, per Cargo.lock) and drop the custom 500 ms ping path orKeepAlive::default()(they overlap; see N3).
- Where:
src/agent.rs:95-102; matching insrc/tmux.rs:2082-2096(detect_attention_dialog, tail = last 12 non-empty lines, case-sensitivecontains) - What: opencode's real permission dialog (from
packages/tui/src/routes/session/permission.tsxand issue reports) renders: header△ Permission required, tool line, then buttonsAllow once / Allow always / Rejectwith⇆ select enter confirm. So:- live:
"Permission required"✓,"Allow "✓ (matches "Allow once/always") - dead:
"Approve ","Do you want to run"(Claude phrasing),"❯ [Y/n]","[y/N]"(opencode uses arrow selection, not y/N prompts) - false-positive risk:
"Allow "is a common English word — a model answer or code line ending in "…Allow something" in the last 12 pane lines flips an idle session to WaitingForPermission. Compare the specificity of the other agents: Codex"› 1."and Pi"↑↓ navigate"are near-unique UI strings. - missed dialogs: opencode's
questiontool (AskUserQuestion) and thedoom_loop"Continue after repeated failures" dialog are not covered, so those stall as WaitingForInput (green) instead of permission (magenta!).
- live:
- Why it matters: status is the core signal of the TUI/
askflow (src/cli.rs:653bails onhas_permission_prompt); both misfires and misses corrupt it for opencode sessions. - Fix: use the exact rendered strings:
["Permission required", "Allow once", "Allow always", "Reject permission"](drop the four dead markers); optionally add the question-dialog header once captured. Validate against a live pane the way the Pi/Codex markers were ("captured from pi 0.84.2 panes" persrc/agent.rs:182-183).
- Where:
src/agent.rs:130-137; consumed bytrim_paneatsrc/cli.rs:729(showrunner askreply extraction) - What: The heuristic trims from the last line matching
line == "❯" || line == ">" || starts_with('╭') || starts_with('└') || starts_with('│') || ≥10-char ─/━ rule.starts_with('│')and bare>are generic: tool output that prints boxed content (tables, tree dumps, captured panes, some LSP output) ending in a│-prefixed line after the real transcript will be treated as chrome and truncated — corrupting the extracted reply. The code comment itself admits it is unvalidated ("may need validation against specific OpenCode TUI themes"). - Why it matters:
askis the cross-session primitive other agents use; a truncated reply is a silent data corruption in that flow. - Fix: capture a real opencode pane (idle + dialog states), pin the exact input-box glyphs, and prefer the distinctive frame (e.g. the bottom border line pattern with its hint text) over any single box character.
- Where:
scripts/lib/ui.sh:448-481(field read at:456); conflicting consumerhelpers/alerts.sh:112-134 - What:
ui.shreadstail -1 opencode-hook.state | cut -d'|' -f6as the alerting PID, then walks the ppid chain. The only in-repo consumer of that same file,alerts.sh, defines a 5-field format (tool|event_type|session_id|description|ts) — no PID field. So either the out-of-repo opencode hook writes 6 fields (andalerts.shparses it wrong) or 5 fields (andui.sh's per-session badge can never light up). Additionally, even when the PID matches, the badge displays the global count on that one session, and only the last line (tail -1) is ever considered. - Why it matters: per-session alert badges are either dead or misattributed;
two consumers with contradictory formats for one state file is a maintenance
trap. (Context:
main.sh/ui.shis legacy — no binding invokes it anymore — which makes the whole block dead weight rather than user-visible, but the task asked for it specifically.) - Fix: document the canonical
opencode-hook.stateschema in one place (header comment inalerts.sh), haveui.shconform to it, and compute a real per-session count; or delete the legacy block.
- Where:
scripts/daemon.sh:53-60(daemon_status),:33-50(daemon_stop,kill -9at:45) - What: Liveness is
kill -0 $pidonly. If the daemon dies and the OS recycles its PID to an unrelated process:daemon_statusreports "running" (sodaemon_startno-ops and the daemon stays dead), anddaemon_stopsendsSIGKILLto the unrelated process at line 45. - Why it matters: a status toggle that lies, and a
kill -9aimed at whatever happens to hold the old PID. Low probability, high impact if hit. - Fix: before trusting or killing, verify identity:
ps -p "$pid" -o comm= | grep -qx showrunner(orpgrep -x showrunner), and only then act; treat mismatch as stale PID and clean up.
- Where:
src/resources.rs:71-74(ticks1 ... unwrap_or(0)) and:79-83 - What: If
read_cpu_ticksfails transiently on the first read for an existing pid,base = 0and the second read's full lifetime tick count is counted as if it happened inside the ~200 ms window → a one-sample CPU% spike for that session. (The mirror case — pid vanishing between passes — is handled correctly bysaturating_sub; pids created during the window are also correct, since their ticks start at 0 at creation. Both were verified against the code, not just asserted.) - Fix: when
ticks1is missing, setbase = ticks2(delta 0) instead of 0.
- Where:
src/resources.rs:54insidesample_sessions - What: The batched sampler still does N
tmux list-panes -t <name>process spawns (one per session, every ~8th tick). A singletmux list-panes -a -F "#{session_name}\t#{pane_pid}"call would fetch all of them — the exact pattern already used bylist_run_sessions(src/tmux.rs:889-898). - Fix: batch the pane-pid lookup; keep
list_pane_pidsfor the single-name callers.
- Where:
src/resources.rs:20, used at:172 - What: RSS KiB = resident pages × 4096 / 1024. On 16K-page (arm64) or 64K kernels the number is off by 4×/16×. Fine on this x86_64 box, wrong elsewhere.
- Fix: read
VmRSS:from/proc/<pid>/status(already in KiB, no page-size assumption) — also one fewer format edge case thanstatm.
- Where:
src/resources.rs:176-182(unused — zero callers outside the module),src/worker.rs:100 - What: The worker spawns
nvidia-smievery 8th tick on every machine, including GPU-less ones where it just fails. The helper that would gate it was written but never wired. - Fix:
let has_gpu = gpu_available();once at worker start; only callgpu_processes()when true — or deletegpu_available().
- Where:
src/resources.rs:186-200 - What:
std::process::Command::output()blocks indefinitely; a wedged nvidia driver (a known nvidia-smi failure mode) hangs the worker thread and freezes all status updates, not just GPU data. - Fix: run it with a deadline (e.g. spawn,
try_waitloop, kill on timeout), or at minimum move it behind thegpu_available()gate and accept the risk in a comment.
- Where:
src/resources.rs:250-279 - What: The pane runs a sleeping Python loop (
time.sleep(0.001)× 200000 ≈ 200 s of near-zero CPU), so the CPU-attribution path is never exercised — onlymem_kb > 0is asserted. If a prior run crashed, the_sr_runtimesession already exists andnew-sessionfails silently (let _ =), the test then measures the stale session, and the finalkill-sessionis best-effort (no cleanup on panic). - Fix: use a real CPU burner (e.g.
yes > /dev/nullor a Python busy loop), assertcpu_percent > 0, kill any pre-existing session first, and clean up in a guard that also runs on failure.
- Where:
sessionizer.tmux:25-26 - What: The comment says this marks "bindings + auto-start only run once
per server (idempotent)", but nothing reads the option. The keybinds
re-run on every source (harmless —
bind-keyoverwrites), and the auto-start is actually guarded by the different option@tmux_sessionizer_daemon_autostart(:38-40). - Fix: delete the dead option and fix the comment, or actually guard the bind block with it.
- Where:
sessionizer.tmux:13-21(discovery),:29, :32(bindings) - What: Discovery runs in the shell that sources the plugin, but when
discovery falls back to bare
showrunner(:20), the tmux server's environment resolves PATH at keypress time — the server's PATH (from when it was started) may lack e.g. linuxbrew dirs, giving a "command not found" error pane. Separately, the resolved path is interpolated into the binding string, so a repo path containing spaces word-splits insidedisplay-popup … -E /path with space/showrunner. - Fix: in the fallback branch, require
command -vto return an absolute path and warn if not; quote the binary in the binding (display-popup … -E '"$SESSIONIZER_BIN"'style — or better, invoke viarun-shell -bwith a properly quoted command).
- Where:
scripts/daemon.sh:16-30 - What: Two near-simultaneous starts (fast double M-a, or auto-start racing
a manual start) both pass
daemon_status, bothnohup— the second loses the bind on127.0.0.1:7878and exits, leaving a dead PID in the file. The next status call cleans it up, but in the meantime the toggle reports "started" while the first instance (unknown to the PID file) is what's actually serving. - Fix: make the PID file the lock:
set -C; echo $! > "$PID_FILE"(O_EXCL via>) and fail the start if the file already exists, or checkfuser 7878/tcpbefore starting.
- Where:
src/agent.rs:70-72; consumersrc/tmux.rs:2040-2059 - What: Verified on this machine: the current npm binary (
opencode.exe, ELF) sets its owncommtoopencode, sopane_comm == "opencode"andpgrep -x opencodeboth work. But opencode installed as a plain node script (older releases) runs withcomm = node, in which case every opencode session reads asFinishedimmediately (same class of failure the Codex comment atsrc/agent.rs:67-69describes for codex). - Fix: at minimum, extend
process_name/the probe for OpenCode to also acceptnodewith an argv check (pgrep -f "opencode"), or document the native-binary requirement.
- Where:
src/ui.rs:543-555 - What:
MB/GBactually hold KiB thresholds of 1 GiB / 1 TiB (misleading names);kb = 1048575renders as1024.0Minstead of1.0G; suffixes are binary units labelled with decimal letters (consistent with theCPU x% · 1.2Gstyle, just worth knowing). - Fix: rename to
KIB_PER_GIB-style or restructure with(value, unit)pairs; optionally use%.0fbelow 10 to get1024M → 1.0Gvia unit promotion.
- Where:
src/ui.rs:539 - What: Rust float formatting rounds half-to-even (
12.5 → "12"); negative and zero are safe (cpu_percent can't go negative —saturating_subupstream). Cosmetic only.
- Where:
src/server.rs:249(custom 500 mscomment("ping")) and:253(KeepAlive::default(), 10 s ping) - What: Both emit SSE comments; the custom path only fires when idle, so it's redundant belt-and-suspenders with KeepAlive. Harmless, but pick one so the keepalive interval has a single owner.
- Where:
Cargo.toml:23, used only atsrc/server.rs:12(use futures_util::stream::Stream) - What: The
Streamtrait is re-exported fromfutures-core;futures-utilis already in the dep graph via axum so this costs nothing at build time, but the minimal honest dependency isfutures-core = "0.3".
- Where:
src/worker.rs:86-87("refresh every 8th tick (~4s)" — a tick is 500 ms sleep plus per-session tmux/git probes, so the real cadence is ~8–24 s);src/resources.rs:1-5("for ALL tmux sessions" — the worker only samples showrunnercm*sessions vialist_sessions;all_sessions_resources()is only used by the ignored test) - Fix: reword both to match actual behavior.
/proc/<pid>/statparsing is done right where it's easy to get wrong:rfind(')')handles comm values containing spaces/parens, and the field indices are correct (ppid = token 1 after comm; utime/stime = tokens 11/12 — verified against the man-page field layout) (src/resources.rs:106-162).- The batched sampler is a genuinely good design: one ppid-map build and one
shared 200 ms CPU window for all sessions instead of N sequential samples
(
src/resources.rs:42-103), with the worker carrying the cache forward so the UI never flickers between samples (src/worker.rs:86-101, 161, 240). - Client-disconnect handling in the SSE stream is correct by construction: axum
drops the response body, the
async_streamgenerator is dropped, and theInfallibleerror type plus the ping fallback mean a failedConfig::load()degrades to a keepalive comment instead of killing the stream (src/server.rs:217-254). shell_escapeuses the correct single-quote idiom ('\''), and tmux's own argument tokenizer honors those quotes, so prompt injection into the pane command is not possible (src/tmux.rs:123-125).- Good security defaults in the API: loopback-only bind (
127.0.0.1:7878,src/cli.rs:113-122),validate_session_namegating all tmux-targeting routes, and a strict allow-list for injected keys (src/server.rs:282-288, 395-402). - The RES column integrates cleanly with the existing autosizing metadata layout
(column dropped when empty, header
RESmaxed into the width, adhoc rows included in sizing) (src/ui.rs:518-526, 590, 1123-1148). - Empirical verification was possible and encouraging: the installed opencode
npm binary self-reports
comm=opencode, so the agent-liveness probe works for current versions (see L10 for the older-install caveat).
Fixed across commits 44b9571 (feat) + a78f29b (fix): all 4 High (H1–H4),
M1, M2, M3, L1–L10, N3, N4, N5.
Not addressed (intentional):
- M4 — resolved by deletion. Commit
461732b("…+ drop legacy bash") removed the superseded legacy bash TUI (scripts/main.sh,scripts/lib/ui.sh,helpers/alerts.sh), so the contradictoryopencode-hook.state6-field reader no longer exists. Onlyscripts/daemon.sh+helpers/alert_status.shremain. - N1 / N2 — cosmetic (
mem_humanunit-constant naming;CPU {:.0}%half-to-even rounding). Still intentionally open.
Verification (at the time): cargo test 116 pass; SSE idle emits ~1 state/6s
(was ~12); daemon status.cache written; recycled-PID rejected; runtime resource
test exercises a real CPU burner.
A follow-up whole-codebase review grouped its findings into six areas. All were
fixed in 91e87f4..f018422 (six commits), followed by a hardening pass
(94cf3ca leaks, 0d52a31 hung-daemon detection, 2edad7b safety tests):
- G1 hooks — authoritative agent-hook model (
AgentEvent::status_hint,PermissionRequestwired),post-event.shas a thin forwarder, pluginhooks.jsoninstalled into the worktree;set_session_envpropagates the daemon port. - G2 consumption —
POST /api/hook→ workerhook_inboxkeyed by cwd → cwd→session correlation → one-shot status override (zero pane-scrape latency). - G3 auto-close — fail-safe dirty check on the record's work dir (error ⇒
dirty), 3-tick finished-stability guard,
auto_closedrecord marker (restore skips it, restart/unarchive clears it), bounded per-session bookkeeping. - G4 notifications — spawn-and-detach with a reaper thread, so a wedged notifier can't stall the single worker polling thread.
- G5 TUI — approve gated on
WaitingForPermission, unique paste buffer per send, content-sized resource panel, worker-hint dedup. - G6 scope — daemon-death local fallback + "daemon down" banner (with
hung-daemon detection),
/api/hookloopback-only, docs.
Verification: cargo test 176 pass; hook → status e2e confirmed over a live SSE
stream; the failsafe-dirty property and the hook loopback policy are now
unit-tested.