diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0606122f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts must stay LF: Windows checkouts with core.autocrlf=true would +# otherwise smudge them to CRLF, and bash in WSL/Linux rejects CRLF scripts +# (engine wrappers are executed directly inside WSL from this working tree). +*.sh text eol=lf diff --git a/README.md b/README.md index e456a154..cb77f74f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ manifest.json ──▶ ringer.py ──▶ N parallel workers (codex exec, each ## Quickstart -Ringer runs on macOS and Linux (Windows via WSL) and needs Python 3.11+. +Ringer runs on macOS, Linux, WSL, and native Windows, and needs Python 3.11+. On native Windows, manifest `check` commands require Git Bash from Git for Windows; if Git Bash is not on the default path, set `RINGER_CHECK_SHELL` to a POSIX `sh`. 1. Install a worker CLI and sign in (Codex is the built-in default engine): @@ -45,6 +45,19 @@ git clone https://github.com/NateBJones-Projects/ringer && cd ringer mkdir -p ~/.config/ringer && cp config.sample.toml ~/.config/ringer/config.toml # optional — sane defaults without it ``` +On native Windows, use PowerShell-friendly commands: + +```powershell +git clone https://github.com/NateBJones-Projects/ringer +cd ringer +New-Item -ItemType Directory -Force "$env:USERPROFILE\.config\ringer" | Out-Null +Copy-Item config.sample.toml "$env:USERPROFILE\.config\ringer\config.toml" # optional — sane defaults without it +python ringer.py demo +python ringer.py hud +``` + +Windows manifest paths can use forward slashes, e.g. `"workdir": "C:/Users//ringer-runs/my-batch"`. Native Windows check execution still requires Git Bash; set `RINGER_CHECK_SHELL` when you need to point Ringer at a specific POSIX shell. + 3. Teach your agent to route work through Ringer: ```bash @@ -156,7 +169,7 @@ Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin close Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one engine block covers every OpenRouter-served model. `config.sample.toml` includes a ready-to-uncomment engine whose `{model}` placeholder is filled per task from the manifest's `"model"` field, with `model_default` as the fallback. The shipped default is OpenRouter's `z-ai/glm-5.2` — roughly $0.74/M input and $2.33/M output (2026-07), about 20-30x cheaper output than frontier coding models; a complete write-code-and-pass-the-check task lands around a penny. -OpenCode ships no OS sandbox, so the engine's `bin` points at an absolute path to `engines/opencode-sandboxed.sh` (ringer does not resolve engine bins relative to the repo): a macOS Seatbelt wrapper that leaves network and reads open but confines writes to the task dir, a per-run scratch dir (wired as the agent's `TMPDIR`/`XDG_CACHE_HOME`), and OpenCode's own state/config dirs. Its `--dangerously-skip-permissions` flag only silences OpenCode's interactive prompts; Seatbelt is the actual containment. Task paths reach the profile as `sandbox-exec -D` parameters rather than string interpolation, so a task dir with quotes or parens can't inject sandbox rules. `--no-sandbox` is wired as the engine's `full_access_args`, so ringer's `allow_full_access` gate still governs escapes. Non-macOS installs need their own sandbox (or full-access mode). +OpenCode ships no OS sandbox, so the engine's `bin` points at an absolute path to `engines/opencode-sandboxed.sh` (ringer does not resolve engine bins relative to the repo): a macOS Seatbelt wrapper that leaves network and reads open but confines writes to the task dir, a per-run scratch dir (wired as the agent's `TMPDIR`/`XDG_CACHE_HOME`), and OpenCode's own state/config dirs. Its `--dangerously-skip-permissions` flag only silences OpenCode's interactive prompts; Seatbelt is the actual containment. Task paths reach the profile as `sandbox-exec -D` parameters rather than string interpolation, so a task dir with quotes or parens can't inject sandbox rules. `--no-sandbox` is wired as the engine's `full_access_args`, so ringer's `allow_full_access` gate still governs escapes. `engines/opencode-sandboxed.sh` is macOS-only; its siblings cover the other platforms with the same contract: `engines/opencode-sandboxed-linux.sh` (Linux/WSL, bubblewrap — install `bubblewrap`) and `engines/opencode-sandboxed-wsl.sh` (Windows: set `bin = "wsl.exe"` and point `args_template` at the script's `/mnt` path; it translates `C:\` task dirs and hands off to the Linux wrapper inside WSL). Setting it up takes about five minutes: @@ -170,9 +183,10 @@ curl -fsSL https://opencode.ai/install | bash opencode auth login # select OpenRouter, paste the key # 3) In ~/.config/ringer/config.toml, uncomment [engines.opencode] and set -# bin to the ABSOLUTE path of engines/opencode-sandboxed.sh in this clone. -# (Linux/WSL: the wrapper is macOS-only — set bin to the opencode binary -# itself; there is no OS write-confinement then, so keep manifests scoped.) +# bin to the ABSOLUTE path of the wrapper for your OS in this clone: +# opencode-sandboxed.sh (macOS Seatbelt), opencode-sandboxed-linux.sh +# (Linux/WSL, needs bubblewrap), or on Windows bin = "wsl.exe" with +# args_template pointing at opencode-sandboxed-wsl.sh's /mnt path. ``` Route with per-task `"engine": "opencode"`, pick the model with per-task `"model": "openrouter/"`, and set reasoning effort via `engine_args`: `["--variant", "low|high|max"]`. A sensible split: mechanical or tightly-specced tasks on the cheap lane, gnarly ones on your frontier engine — the executed check catches shortfalls either way, and `swarm_runs` rows tell you whether the cheap lane's pass rate holds. @@ -209,6 +223,8 @@ The top of the page is the run's live results document: what the job is, a progr Multiple swarms at once is the designed-for case: run three batches under three identities and Ringside shows all three, live. `--browser` opens a simpler per-run fallback dashboard, and `--no-dashboard` runs headless. +On native Windows, per-task child-process counts currently read zero in the dashboard; run status, logs, and verdicts still work. + A native desktop build (Tauri, under `hud/`) exists as a v0.1.1 prototype; the web dashboard is currently ahead of it — start there. ## The eval loop diff --git a/config.sample.toml b/config.sample.toml index 657c7359..6e14aa69 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -1,5 +1,6 @@ # Sample config for ringer.py. -# Copy to ~/.config/ringer/config.toml or pass with --config /path/to/config.toml. +# Copy to ~/.config/ringer/config.toml (Windows: $env:USERPROFILE\.config\ringer\config.toml) +# or pass with --config /path/to/config.toml. # Default identity stamped into state JSON and eval rows. Resolution order: # --identity flag > FLEET_IDENTITY / RINGER_IDENTITY env > a .fleet-agent file @@ -15,7 +16,7 @@ state_dir = "~/.ringer" dashboard_port_base = 8787 # Optional native HUD app path. Public installs should omit this and let the -# dashboard open in the browser. +# dashboard open in the browser. This /Applications example is macOS-only. # hud_app_path = "/Applications/Ringside.app" # Belt-and-suspenders full-access gate. A task with "full_access": true will @@ -43,6 +44,11 @@ bin = "codex" # - {taskdir}: task working directory # - {spec}: task prompt/spec # - {access_args}: expands to sandbox_args or full_access_args +# - {model_args}: expands to "-m " only when the manifest task sets +# "model" or this engine sets model_default; otherwise it expands to nothing, +# preserving Codex's configured default. Setting model_default = "gpt-5.6-sol" +# pins the model instead of inheriting ~/.codex/config.toml drift and makes +# scoreboard attribution exact. # - {engine_args}: expands to the task's optional "engine_args" list — the orchestrator # sets per-task flags here, e.g. ["-c", "model_reasoning_effort=medium"] to match # reasoning depth to task difficulty instead of inheriting the CLI-wide default @@ -50,6 +56,7 @@ args_template = [ "exec", "--skip-git-repo-check", "{access_args}", + "{model_args}", "{engine_args}", "-C", "{taskdir}", @@ -112,16 +119,31 @@ token_regex = "tokens\\s+used\\s*:?\\s*([0-9][0-9,]*)" # the manifest's "model" field, falling back to model_default below. Example # default: GLM-5.2 (z-ai/glm-5.2, roughly $0.74/M input, $2.33/M output as of # 2026-07) — the cheap-intelligence lane. -# OpenCode has no OS sandbox, so `bin` points at engines/opencode-sandboxed.sh -# (macOS Seatbelt: network + reads open, writes confined to the task dir, a -# per-run scratch dir, and OpenCode's state/config dirs). Auth: put your +# OpenCode has no OS sandbox, so `bin` points at a wrapper (all three share one +# contract: network + reads open, writes confined to the task dir, a per-run +# scratch dir, and OpenCode's state/config dirs): +# macOS engines/opencode-sandboxed.sh (Seatbelt) +# Linux/WSL engines/opencode-sandboxed-linux.sh (bubblewrap; install bwrap) +# Windows bin = "wsl.exe" bridging into engines/opencode-sandboxed-wsl.sh +# (see the Windows example below; opencode + bwrap live inside WSL) +# Auth: put your # OpenRouter key where your OpenCode version expects it — commonly # ~/.local/share/opencode/auth.json ({"openrouter": {"type": "api", "key": "..."}}); # confirm the path with your installed CLI. # Per-task engine_args can set reasoning effort ("--variant", "low|high|max"). # Uncomment and set an absolute path for `bin` to enable. # [engines.opencode] +# macOS wrapper example (Linux: swap in opencode-sandboxed-linux.sh): # bin = "/absolute/path/to/ringer/engines/opencode-sandboxed.sh" +# Windows example (verified 2026-07-09: ringer.py on Windows, sandbox in WSL): +# bin = "C:/Windows/System32/wsl.exe" +# args_template = [ +# "-d", "Ubuntu", "-e", +# "/mnt//path/to/ringer/engines/opencode-sandboxed-wsl.sh", +# "{taskdir}", "{access_args}", "run", "-m", "{model}", +# "--dangerously-skip-permissions", "--format", "json", +# "{engine_args}", "--dir", "{taskdir}", "{spec}", +# ] # model_default = "openrouter/z-ai/glm-5.2" # args_template = [ # "{taskdir}", diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index c18ad011..47cc3e8f 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -1260,8 +1260,12 @@

Ringside mission control

function fileHref(path) { const text = String(path || "").trim(); if (!text) return ""; - if (/^(?:file|https?):/i.test(text)) return text; - return `file://${encodeURI(text).replace(/#/g, "%23")}`; + const normalized = text.replace(/\\/g, "/"); + if (/^(?:file|https?):/i.test(normalized)) return normalized; + if (/^[A-Za-z]:\//.test(normalized)) { + return `file:///${encodeURI(normalized).replace(/#/g, "%23")}`; + } + return `file://${encodeURI(normalized).replace(/#/g, "%23")}`; } function artifactWrapperHref(run, task, sourcePath) { @@ -1285,7 +1289,7 @@

Ringside mission control

const links = []; if (task.taskdir) links.push(linkHtml(fileHref(task.taskdir), "taskdir")); - const logPath = task.log_path || (task.taskdir ? `${task.taskdir}/worker.log` : ""); + const logPath = task.log_path || (task.taskdir ? `${String(task.taskdir).replace(/\\/g, "/")}/worker.log` : ""); if (logPath) { links.push(linkHtml(artifactWrapperHref(run, task, logPath) || fileHref(logPath), "worker.log")); } @@ -1301,7 +1305,7 @@

Ringside mission control

} function workerLogPath(task) { - return String(task?.log_path || (task?.taskdir ? `${task.taskdir}/worker.log` : "") || ""); + return String(task?.log_path || (task?.taskdir ? `${String(task.taskdir).replace(/\\/g, "/")}/worker.log` : "") || ""); } function workerLogKey(task) { diff --git a/dashboard/ringside.html b/dashboard/ringside.html index d20e5ae0..79d5ecf3 100644 --- a/dashboard/ringside.html +++ b/dashboard/ringside.html @@ -105,32 +105,45 @@ border-bottom: 1px solid var(--hairline); gap: 10px; } - .machine-strip { + .running-now { display: flex; - gap: 4px; + gap: 7px; align-items: center; - min-width: 60px; - max-width: 220px; - flex: 0 1 auto; - } - .machine-strip button { - flex: 1 1 0; - min-width: 14px; - height: 7px; - border: 0; - border-radius: 4px; - padding: 0; - background: var(--waiting); - opacity: .45; - cursor: pointer; + min-width: 0; + flex: 1 1 auto; + overflow-x: auto; + scrollbar-width: thin; } - .machine-strip button.live { background: var(--accent); opacity: 1; } - .machine-strip button.pass { background: var(--pass); opacity: .9; } - .machine-strip button.fail { background: var(--fail); opacity: .9; } - .machine-strip button[aria-current="true"] { outline: 1.5px solid var(--ink); outline-offset: 2px; } - @media (prefers-reduced-motion: no-preference) { - .machine-strip button.live { animation: pulse 1.4s ease-in-out infinite; } + .running-now:empty { display: none; } + .run-switch { + flex: 0 0 auto; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border: 1px solid var(--hairline); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + font-size: 12px; + line-height: 1; + } + .run-switch:hover { border-color: var(--accent); } + .run-switch[aria-current="true"] { + border-color: var(--accent); + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); } + .run-switch-name { + max-width: 18ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; + } + .run-switch-progress { color: var(--muted); font-size: 11px; } + .run-switch .live-dot { width: 7px; height: 7px; flex-basis: 7px; } .artifact-tools button { padding: 7px 14px; border: 1px solid var(--hairline); @@ -160,7 +173,7 @@ } select:focus-visible, summary.run-head:focus-visible, - .machine-strip button:focus-visible { + .run-switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } @@ -261,27 +274,51 @@ @media (prefers-reduced-motion: no-preference) { .rounds .working, .rounds .retry { animation: pulse 1.4s ease-in-out infinite; } } - .legend { font-size: 12.5px; color: var(--muted); margin: 0 0 clamp(12px, 2vw, 18px); } - section h2 { font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px; padding-bottom: 8px; border-bottom: 1px solid var(--hairline); } - .workers { margin-top: clamp(14px, 2vw, 20px); } - .worker { + .workers { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: clamp(14px, 2vw, 20px); + } + .workers > .empty { grid-column: 1 / -1; } + .worker-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: 8px; + background: var(--surface); + } + .worker-card.expanded { grid-column: 1 / -1; } + .worker-card-toggle { width: 100%; display: grid; - grid-template-columns: 18px minmax(0,1fr) auto auto; - gap: 4px 12px; align-items: baseline; - padding: 12px 0; border: 0; border-bottom: 1px solid var(--hairline); + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px 12px; + align-items: center; + padding: 12px; + border: 0; background: transparent; text-align: left; } - .worker:hover .name, - .worker:focus-visible .name { color: var(--accent); } - .worker[aria-expanded="true"] .name { color: var(--accent); } + .worker-card-toggle:hover .name, + .worker-card-toggle:focus-visible .name, + .worker-card-toggle[aria-expanded="true"] .name { color: var(--accent); } + .worker-card-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + .worker-card-head { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + } .glyph { width: 11px; height: 11px; border-radius: 50%; align-self: center; } .glyph.pass { background: var(--pass); } .glyph.working { background: var(--accent); } @@ -291,21 +328,31 @@ @media (prefers-reduced-motion: no-preference) { .glyph.working, .glyph.retry { animation: pulse 1.4s ease-in-out infinite; } } - .worker .name { font-weight: 650; font-size: 15px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .worker .state { font-size: 13px; font-weight: 650; white-space: nowrap; } + .worker-card .name { font-weight: 650; font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .worker-card .state { font-size: 12px; font-weight: 650; white-space: nowrap; } .state.pass { color: var(--pass); } .state.working { color: var(--accent); } .state.retry, .state.fail { color: var(--fail); } .state.waiting { color: var(--waiting); } - .worker .time { font-size: 12.5px; color: var(--muted); white-space: nowrap; } - .worker .activity { - grid-column: 2 / -1; font-size: 13px; color: var(--muted); + .worker-card .meta { + grid-column: 1 / -1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 11.5px; + } + .worker-card .activity { + grid-column: 1 / -1; + font-size: 12.5px; + color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .stream { - padding: 0 0 14px 30px; - border-bottom: 1px solid var(--hairline); + padding: 12px; + border-top: 1px solid var(--hairline); } .stream-head { display: flex; @@ -491,6 +538,7 @@ @media (max-width: 760px) { .topbar-main { flex-wrap: wrap; gap: 6px 10px; } + .running-now { order: 3; flex-basis: 100%; } .run-head .clock { width: 100%; margin-left: 21px; } .artifact-layout { min-height: auto; @@ -509,22 +557,9 @@ min-height: 58vh; } } - @media (max-width: 560px) { - .worker { - grid-template-columns: 18px minmax(0,1fr); - } - .worker .state, - .worker .time, - .worker .activity { - grid-column: 2 / -1; - } - .worker .state, - .worker .time { - white-space: normal; - } - .stream { - padding-left: 0; - } + @media (max-width: 720px) { + .workers { grid-template-columns: minmax(0, 1fr); } + .worker-card.expanded { grid-column: auto; } } @@ -536,7 +571,7 @@ Ringside -
+
@@ -548,7 +583,7 @@
No artifact selected.
- +
No artifacts yet.
@@ -574,7 +609,7 @@ libraryError: null, tab: normalizeTab(localStorage.getItem(TAB_KEY)) || "live", hasStoredTab: Boolean(normalizeTab(localStorage.getItem(TAB_KEY))), - expanded: null, + expanded: new Map(), logTimer: null, artifactName: sessionStorage.getItem(ARTIFACT_KEY) || "", artifactManual: Boolean(sessionStorage.getItem(ARTIFACT_KEY)), @@ -583,13 +618,15 @@ frameContent: "", frameRequest: 0, rendering: false, - runOpen: {} + runOpen: {}, + focusedRunId: "" }; const els = { topDot: document.getElementById("top-dot"), reconnect: document.getElementById("reconnect"), clock: document.getElementById("clock"), + runningNow: document.getElementById("running-now"), runs: document.getElementById("runs"), artifactPicker: document.getElementById("artifact-picker"), artifactStatus: document.getElementById("artifact-status"), @@ -660,6 +697,10 @@ return String(task.key || task.id || task.name || `task-${index}`); } + function expansionKey(runIdValue, taskKeyValue) { + return JSON.stringify([String(runIdValue), String(taskKeyValue)]); + } + function normalizeRuns(payload) { const raw = Array.isArray(payload) ? payload : (Array.isArray(payload?.runs) ? payload.runs : []); const runs = raw.filter(Boolean).map((run, index) => { @@ -670,6 +711,10 @@ const stateName = rawState === "died" ? "died" : (live ? "live" : "finished"); const pass = numberOrZero(run.pass ?? summary.pass ?? totals.pass); const fail = numberOrZero(run.fail ?? summary.fail ?? totals.fail); + const doneSource = run.done ?? summary.done ?? totals.done; + const done = doneSource === undefined || doneSource === null + ? pass + fail + : numberOrZero(doneSource); const tokens = numberOrZero(run.tokens ?? summary.tokens ?? totals.tokens); return { ...run, @@ -682,6 +727,7 @@ elapsed_s: elapsedForRun({...run, state: stateName}), pass, fail, + done, tokens, tasks: Array.isArray(run.tasks) ? run.tasks : [] }; @@ -725,6 +771,17 @@ return tail.map(line => String(line).trim()).filter(Boolean).slice(-1)[0] || ""; } + function runIsRunningNow(run) { + return run.state === "live" + && state.active + && typeof state.active === "object" + && Object.prototype.hasOwnProperty.call(state.active, run.__id); + } + + function runningNowRuns() { + return state.runs.filter(runIsRunningNow); + } + function setTab(_tab, _persist) { /* unified page — tabs removed */ } function preserveScroll(mutate) { @@ -828,11 +885,78 @@ } function renderTop() { - const anyLive = state.runs.some(run => run.state === "live"); + const anyLive = runningNowRuns().length > 0; els.topDot.classList.toggle("live", anyLive); els.reconnect.hidden = !(state.runError || state.libraryError); } + function effectiveFocusedRunId(liveRuns) { + if (liveRuns.some(run => run.__id === state.focusedRunId)) return state.focusedRunId; + return liveRuns.find(run => run.run_name === state.artifactName)?.__id || ""; + } + + function renderRunningNow() { + const liveRuns = runningNowRuns(); + const staging = document.createElement("div"); + if (liveRuns.length >= 2) { + const activeRunId = effectiveFocusedRunId(liveRuns); + liveRuns.forEach(run => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "run-switch"; + button.dataset.key = run.__id; + button.setAttribute("aria-current", String(run.__id === activeRunId)); + const total = run.tasks.length; + const done = Math.min(total, Math.max(0, run.done)); + button.setAttribute("aria-label", `${run.run_name}, live, ${done} of ${total} tasks done`); + button.addEventListener("click", () => focusRun(run.__id)); + + const dot = document.createElement("span"); + dot.className = "live-dot live"; + dot.setAttribute("aria-hidden", "true"); + const name = document.createElement("span"); + name.className = "run-switch-name"; + name.textContent = run.run_name; + const progress = document.createElement("span"); + progress.className = "run-switch-progress mono"; + progress.textContent = `${done}/${total}`; + button.append(dot, name, progress); + staging.appendChild(button); + }); + } + state.rendering = true; + morphChildren(els.runningNow, staging); + state.rendering = false; + } + + function focusRun(runIdValue) { + const run = state.runs.find(item => item.__id === runIdValue); + if (!run || !runIsRunningNow(run)) return; + state.focusedRunId = run.__id; + state.artifactName = run.run_name; + state.artifactManual = true; + state.artifactVersion = "live"; + state.frameKey = ""; + state.runOpen[run.__id] = true; + sessionStorage.setItem(ARTIFACT_KEY, run.run_name); + renderRunningNow(); + renderArtifacts(); + renderArtifactControls(); + renderLive(); + loadArtifactFrame(true); + requestAnimationFrame(() => requestAnimationFrame(() => { + const target = [...els.runs.querySelectorAll("details.run")] + .find(section => section.dataset.key === run.__id); + if (!target) return; + target.open = true; + state.runOpen[run.__id] = true; + target.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + block: "start" + }); + })); + } + function renderLive() { const staging = document.createElement("div"); if (!state.runs.length) { @@ -870,20 +994,20 @@ const head = document.createElement("summary"); head.className = "corner run-head"; const dot = document.createElement("span"); - dot.className = `live-dot ${run.state === "live" ? "live" : ""}`; + dot.className = `live-dot ${runIsRunningNow(run) ? "live" : ""}`; dot.setAttribute("aria-hidden", "true"); const title = document.createElement("span"); title.className = "eyebrow"; const titleBold = document.createElement("b"); - titleBold.textContent = `Round ${roundOf(run)}`; + titleBold.textContent = run.run_name; title.appendChild(titleBold); const identity = document.createElement("span"); identity.className = "identity"; - identity.textContent = `${run.tasks.length} agent${run.tasks.length === 1 ? "" : "s"} · ${run.identity}`; + identity.textContent = `Round ${roundOf(run)} · ${run.identity}`; const stats = document.createElement("span"); stats.className = "clock mono run-stats"; const outcome = run.state === "live" - ? `${run.pass} passed so far` + ? `${Math.min(run.tasks.length, run.done)}/${run.tasks.length} done` : (run.fail > 0 ? `${run.pass} passed · ${run.fail} failed` : `all ${run.pass} passed`); const tok = run.tokens > 0 ? ` · ${formatTokens(run.tokens)}` : ""; stats.textContent = `${outcome} · ${formatDuration(run.elapsed_s)}${tok}`; @@ -909,11 +1033,6 @@ ); section.appendChild(rounds); - const legend = document.createElement("p"); - legend.className = "legend"; - legend.textContent = `${counts.pass} finished & checked · ${counts.working} working · ${counts.retry} sent back · ${counts.waiting} waiting · ${counts.fail} failed`; - section.appendChild(legend); - const workers = document.createElement("div"); workers.className = "workers"; if (!run.tasks.length) { @@ -925,13 +1044,19 @@ run.tasks.forEach((task, taskIndex) => { const key = taskKey(task, taskIndex); const kind = taskKind(task); - const isExpanded = state.expanded && state.expanded.runId === run.__id && state.expanded.taskKey === key; - const row = document.createElement("button"); - row.className = "worker"; - row.dataset.key = `worker:${key}`; - row.type = "button"; - row.setAttribute("aria-expanded", String(Boolean(isExpanded))); - row.addEventListener("click", () => toggleWorker(run, task, taskIndex)); + const expandedKey = expansionKey(run.__id, key); + const expanded = state.expanded.get(expandedKey); + const isExpanded = Boolean(expanded); + const card = document.createElement("article"); + card.className = `worker-card${isExpanded ? " expanded" : ""}`; + card.dataset.key = `worker:${key}`; + + const toggle = document.createElement("button"); + toggle.className = "worker-card-toggle"; + toggle.type = "button"; + toggle.setAttribute("aria-expanded", String(isExpanded)); + if (isExpanded) toggle.setAttribute("aria-controls", `stream-${runIndex}-${taskIndex}`); + toggle.addEventListener("click", () => toggleWorker(run.__id, key)); const glyph = document.createElement("span"); glyph.className = `glyph ${kind}`; @@ -939,24 +1064,39 @@ const name = document.createElement("span"); name.className = "name"; name.textContent = key; + const cardHead = document.createElement("span"); + cardHead.className = "worker-card-head"; + cardHead.append(name, glyph); const taskState = document.createElement("span"); taskState.className = `state ${kind}`; taskState.textContent = taskStateText(kind); - const time = document.createElement("span"); - time.className = "time mono"; - time.textContent = kind === "waiting" ? "—" : formatDuration(task.elapsed_s); - row.append(glyph, name, taskState, time); - - const activity = taskIsRunning(task) ? taskActivity(task) : ""; - if (activity) { - const line = document.createElement("span"); - line.className = "activity"; - line.textContent = activity; - row.appendChild(line); + const engine = String(task.engine || "").trim() || "engine not reported"; + const model = String(task.model || "").trim(); + const metaParts = [model ? `${engine} · ${model}` : engine]; + metaParts.push(kind === "waiting" ? "—" : formatDuration(task.elapsed_s)); + const attempts = numberOrZero(task.attempts); + if (attempts > 1) metaParts.push(`attempt ${attempts}`); + if (numberOrZero(task.tokens) > 0) metaParts.push(formatTokens(task.tokens)); + const meta = document.createElement("span"); + meta.className = "meta mono"; + meta.textContent = metaParts.join(" · "); + toggle.setAttribute("aria-label", `${key}, ${taskStateText(kind)}, ${metaParts.join(", ")}`); + toggle.append(cardHead, taskState, meta); + const activityText = taskIsRunning(task) ? (taskActivity(task) || "No activity reported yet.") : ""; + if (activityText) { + const activity = document.createElement("span"); + activity.className = "activity"; + activity.textContent = activityText; + toggle.appendChild(activity); } - workers.appendChild(row); + card.appendChild(toggle); - if (isExpanded) workers.appendChild(renderStream(run, task, taskIndex)); + if (expanded) { + const stream = renderStream(run, task, taskIndex, expanded, expandedKey); + stream.id = `stream-${runIndex}-${taskIndex}`; + card.appendChild(stream); + } + workers.appendChild(card); }); section.appendChild(workers); staging.appendChild(section); @@ -969,11 +1109,12 @@ syncLogPolling(); } - function renderStream(run, task, taskIndex) { + function renderStream(run, task, taskIndex, expanded, expandedKey) { const panel = document.createElement("div"); panel.className = "stream"; panel.dataset.stream = "true"; panel.dataset.key = `stream:${taskKey(task, taskIndex)}`; + panel.dataset.expansionKey = expandedKey; // The brief: the exact prompt this model was handed, plus the pass // test that will judge its work. Watchers should never have to guess @@ -1013,21 +1154,21 @@ const pre = document.createElement("pre"); pre.className = "stream-log"; pre.tabIndex = 0; - pre.textContent = state.expanded?.logText || "No log output yet."; + pre.textContent = expanded.logText || "No log output yet."; pre.addEventListener("scroll", () => { const distance = pre.scrollHeight - pre.scrollTop - pre.clientHeight; - if (state.expanded) { - state.expanded.pinned = distance < 28; - state.expanded.scrollTop = pre.scrollTop; + if (state.expanded.get(expandedKey) === expanded) { + expanded.pinned = distance < 28; + expanded.scrollTop = pre.scrollTop; } }); panel.appendChild(pre); - if (state.expanded?.error) { + if (expanded.error) { const error = document.createElement("p"); error.className = "catch"; const bold = document.createElement("b"); bold.textContent = "Log stream unavailable:"; - error.append(bold, document.createTextNode(` ${state.expanded.error}`)); + error.append(bold, document.createTextNode(` ${expanded.error}`)); panel.appendChild(error); } @@ -1062,55 +1203,57 @@ } } requestAnimationFrame(() => { - if (state.expanded?.pinned !== false) { + if (expanded.pinned !== false) { pre.scrollTop = pre.scrollHeight; } else { - pre.scrollTop = state.expanded?.scrollTop || 0; + pre.scrollTop = expanded.scrollTop || 0; } }); return panel; } - function toggleWorker(run, task, taskIndex) { - const key = taskKey(task, taskIndex); - if (state.expanded && state.expanded.runId === run.__id && state.expanded.taskKey === key) { - closeWorker(); + function toggleWorker(runIdValue, taskKeyValue) { + const key = expansionKey(runIdValue, taskKeyValue); + if (state.expanded.has(key)) { + closeWorker(key); return; } - state.expanded = { - runId: run.__id, - taskKey: key, + state.expanded.set(key, { + runId: runIdValue, + taskKey: taskKeyValue, logText: "", error: "", pinned: true, scrollTop: 0, finalFetched: false - }; + }); renderLive(); - fetchExpandedLog(); + const found = findExpandedTask(state.expanded.get(key)); + if (found && taskIsRunning(found.task)) fetchExpandedLog(key); } - function closeWorker() { - state.expanded = null; - if (state.logTimer) { + function closeWorker(key) { + if (key) state.expanded.delete(key); + else state.expanded.clear(); + if (!hasRunningExpandedTasks() && state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; } renderLive(); } - function findExpandedTask() { - if (!state.expanded) return null; - const run = state.runs.find(item => item.__id === state.expanded.runId); + function findExpandedTask(expanded) { + if (!expanded) return null; + const run = state.runs.find(item => item.__id === expanded.runId); if (!run) return null; - const index = run.tasks.findIndex((task, taskIndex) => taskKey(task, taskIndex) === state.expanded.taskKey); + const index = run.tasks.findIndex((task, taskIndex) => taskKey(task, taskIndex) === expanded.taskKey); if (index < 0) return null; return {run, task: run.tasks[index], index}; } - async function fetchExpandedLog() { - const expanded = state.expanded; - const found = findExpandedTask(); + async function fetchExpandedLog(key) { + const expanded = state.expanded.get(key); + const found = findExpandedTask(expanded); if (!expanded || !found) return; const path = `/logs/${encodeURIComponent(found.run.__id)}/${encodeURIComponent(expanded.taskKey)}`; try { @@ -1119,50 +1262,70 @@ const raw = await response.text(); // Worker logs carry raw terminal output; strip ANSI escapes for the stream view. const text = raw.replace(/\[[0-9;?]*[ -\/]*[@-~]/g, "").replace(/\[[0-9;]{1,6}m/g, ""); - if (state.expanded !== expanded) return; + if (state.expanded.get(key) !== expanded) return; expanded.logText = text || "No log output yet."; expanded.error = ""; - updateStreamText(expanded.logText); + updateStreamText(key, expanded.logText); } catch (error) { - if (state.expanded !== expanded) return; + if (state.expanded.get(key) !== expanded) return; expanded.error = error?.message || "could not read log"; renderLive(); } } - function updateStreamText(text) { - const pre = els.runs.querySelector(".stream pre.stream-log"); + function updateStreamText(key, text) { + const stream = [...els.runs.querySelectorAll(".stream[data-expansion-key]")] + .find(item => item.dataset.expansionKey === key); + const pre = stream?.querySelector("pre.stream-log"); if (!pre) return; - const shouldPin = state.expanded?.pinned !== false || pre.scrollHeight - pre.scrollTop - pre.clientHeight < 28; + const expanded = state.expanded.get(key); + if (!expanded) return; + const shouldPin = expanded.pinned !== false || pre.scrollHeight - pre.scrollTop - pre.clientHeight < 28; const scrollTop = pre.scrollTop; pre.textContent = text || "No log output yet."; if (shouldPin) { pre.scrollTop = pre.scrollHeight; } else { pre.scrollTop = scrollTop; - if (state.expanded) state.expanded.scrollTop = scrollTop; + expanded.scrollTop = scrollTop; } } + function hasRunningExpandedTasks() { + return [...state.expanded.values()].some(expanded => { + const found = findExpandedTask(expanded); + return found && taskIsRunning(found.task); + }); + } + + function fetchExpandedLogs() { + [...state.expanded.entries()].forEach(([key, expanded]) => { + const found = findExpandedTask(expanded); + if (found && taskIsRunning(found.task)) fetchExpandedLog(key); + }); + } + function syncLogPolling() { - if (!state.expanded) return; - const found = findExpandedTask(); - if (!found) { - closeWorker(); - return; - } - const running = taskIsRunning(found.task); - if (running && !state.logTimer) { - state.logTimer = setInterval(fetchExpandedLog, 1000); + [...state.expanded.entries()].forEach(([key, expanded]) => { + const found = findExpandedTask(expanded); + if (!found) { + state.expanded.delete(key); + return; + } + if (taskIsRunning(found.task)) expanded.finalFetched = false; + if (!taskIsRunning(found.task) && !expanded.finalFetched) { + expanded.finalFetched = true; + fetchExpandedLog(key); + } + }); + const hasRunning = hasRunningExpandedTasks(); + if (hasRunning && !state.logTimer) { + state.logTimer = setInterval(fetchExpandedLogs, 1000); } - if (!running && state.logTimer) { + if (!hasRunning && state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; } - if (!running && !state.expanded.finalFetched) { - state.expanded.finalFetched = true; - fetchExpandedLog(); - } } function normalizeArtifactState(value) { @@ -1450,41 +1613,10 @@ state.runError = error?.message || "runs unavailable"; } renderTop(); - renderMachineStrip(); + renderRunningNow(); renderLive(); } - function renderMachineStrip() { - const strip = document.getElementById("machine-strip"); - if (!strip) return; - const runs = [...state.runs].sort((a, b) => parseTime(a.started_at) - parseTime(b.started_at)).slice(-10); - const staging = document.createElement("div"); - runs.forEach(run => { - const seg = document.createElement("button"); - seg.type = "button"; - seg.dataset.key = run.__id; - seg.className = run.state === "live" ? "live" : (run.fail > 0 || run.state === "died" ? "fail" : "pass"); - seg.title = `${run.run_name} — ${run.state === "live" ? "live" : run.state} (${run.identity})`; - seg.setAttribute("aria-label", seg.title); - seg.setAttribute("aria-current", String(run.run_name === state.artifactName)); - seg.addEventListener("click", () => { - state.artifactName = run.run_name; - state.artifactManual = true; - state.artifactVersion = "live"; - state.frameKey = ""; - sessionStorage.setItem(ARTIFACT_KEY, run.run_name); - renderArtifacts(); - renderArtifactControls(); - renderLive(); - loadArtifactFrame(true); - }); - staging.appendChild(seg); - }); - state.rendering = true; - morphChildren(strip, staging); - state.rendering = false; - } - async function fetchLibrary() { try { const response = await fetch("/api/library", {cache: "no-store"}); @@ -1498,6 +1630,7 @@ renderTop(); renderArtifacts(); renderArtifactControls(); + renderRunningNow(); loadArtifactFrame(false); } els.artifactVersion.addEventListener("change", () => { @@ -1524,16 +1657,20 @@ state.artifactVersion = "live"; state.frameKey = ""; sessionStorage.setItem(ARTIFACT_KEY, name); + state.focusedRunId = ""; renderArtifacts(); renderArtifactControls(); + renderRunningNow(); + renderLive(); loadArtifactFrame(true); }); document.addEventListener("keydown", event => { - if (event.key === "Escape" && state.expanded) closeWorker(); + if (event.key === "Escape" && state.expanded.size) closeWorker(); }); tickClock(); renderTop(); + renderRunningNow(); renderLive(); renderArtifacts(); fetchRuns(); diff --git a/docs/MODEL-NOTES.md b/docs/MODEL-NOTES.md index 93895bda..6fb4d01c 100644 --- a/docs/MODEL-NOTES.md +++ b/docs/MODEL-NOTES.md @@ -72,6 +72,20 @@ checks and raw logs support — no vibes, no worker self-reports. check. Review lane found the HIGH that mattered (sync cursor skipping a half-written trailing line). Codex is the proven lane for both sides of the review->fix loop on this codebase. +- 2026-07-09 — Windows/Linux portability job (first runs on a native Windows + machine): code-review 5/5 scouts passed (1 retry, ~145-590s, ~67k tok on the + retried one); code-fix 6 distinct tasks all substance-green on attempt 1, + including a surgical 8300-line-file patch and a 9-file test-suite sweep + (132 tests green, 122k tok). Two caveats from the raw logs, neither a + competence gap: (a) the codex sandbox on Windows has no `python`/`py` on + PATH, so workers cannot self-run Python verify commands — they improvised + (PowerShell mirrors, an embedded pyRevit python) or skipped; write specs so + self-verification is best-effort and let the executed check carry the + verdict. (b) the test-sweep worker did the whole job but never wrote the + required fix-summary.md, failing the contract check twice — restate the + summary file as a deliverable, not paperwork. Workers were also wrongly + suspected of cp1252 mojibake; the corruption was the fix-swarm exporter's + text-mode capture (fixed 2026-07-09), so don't demote on that evidence. ## glm-5.2 via opencode (`openrouter/z-ai/glm-5.2`) @@ -89,6 +103,10 @@ checks and raw logs support — no vibes, no worker self-reports. openrouter-image commands, idempotent batch-runner spec): 3/3 passed on attempt 1, ~14.5k tokens each. The "execute these exact commands, do not improve them" spec pattern is fully reliable for glm-5.2. +- 2026-07-09 — first run on the Windows machine, and first through the new + wsl.exe → bwrap sandbox lane (engines/opencode-sandboxed-wsl.sh): one-task + file-creation probe PASS attempt 1, 5s, 8k tokens. The Windows→WSL opencode + engine lane is live and verified end to end. - 2026-07-06 — backfill/seed script for the model log (252-line stdlib CLI with a run-state join, 3-level mapping precedence, never-overwrite and @@ -161,6 +179,17 @@ checks and raw logs support — no vibes, no worker self-reports. to k2.7. +## grok-4.5 via opencode (`openrouter/x-ai/grok-4.5`) + +- xAI's July 2026 flagship (default model in Grok Build CLI; $2/M in, $6/M out + on OpenRouter as of 2026-07). +- 2026-07-09 — audition (exploration slot, Windows→WSL bwrap sandbox lane): + short mechanical coding task with an executed-output check (script run, + exact stdout compared): PASS attempt 1, 10.2s, 8.8k tokens (~1-2¢). Clean + idiomatic artifact. Untested→probation; next rung is a real code-fix or + docs lane in a batch. Note the flat-plan Grok Build CLI route would make + this model ~$0 marginal — worth wiring if the subscription exists. + ## grok-build (Grok CLI engine, flat plan) - 2026-07-06 — first outing (elsas-website demo), engine added same day: @@ -254,6 +283,20 @@ checks and raw logs support — no vibes, no worker self-reports. checks must be prefix-tolerant (workers legitimately trim slugs); (2) any heading-regex must tolerate numbered headings ("## 3. Type / Typography"). Both failures looked like worker laziness until the raw logs said otherwise. +- 2026-07-09 — first Windows-native runs, three lessons: (1) a worktrees-mode + task whose previous FAILED worktree still exists dies instantly (0.0s, + status fail, EMPTY error/log) when `git worktree add` collides — on OneDrive + the metadata under .git/worktrees can also survive `worktree remove` with + Permission denied; prune/delete stale worktrees before re-running a failed + key, and the instant-fail-with-no-error signature means exactly this. + (2) the fix-swarm patch exporter captured `git diff` in text mode — cp1252 + decode corrupted every non-ASCII byte and injected CRLF, so patches from + correct worktrees failed to apply; fixed to byte-mode capture (round-1 + ASCII-only patches applied fine, which hid it). (3) the day's only two + recorded FAILs that reached the model log were an orchestrator check bug + (importlib without sys.modules registration) and the fix-summary contract + miss noted under codex — first-try rates for 2026-07-09 code-fix are + depressed by the former. - 2026-07-06 — elsas-website demo, check-craft in BOTH directions: (1) a fixed 800-char body floor failed a worker for faithfully converting genuinely tiny source posts — floor must scale with the source; (2) a citation gate treating @@ -269,3 +312,21 @@ checks and raw logs support — no vibes, no worker self-reports. ## codex (2026-07-06, bench-operator-proofing) - 8/8 code-feature tasks passed attempt 1 across 3 rounds (worktrees mode, Python harness refactor; 108k-406k tokens/task). Specs embedded the approved architecture doc + exact file ownership; checks built fresh uv venvs and ran the full pytest suite. - Lesson (check design, not model): all 3 post-integration bugs were invisible to the checks — a test that passed only because the worker's worktree lacked .env, a `--help`-only assertion missing a runtime importlib/sys.modules bug (py3.12 dataclasses), and bare console-script names failing outside activated venvs. Checks should exercise one real invocation from a cold shell, not just --help. + +## nvidia/nemotron-3-super-120b-a12b:free +- 2026-07-08 (research, content-strategy-recon): FAIL x2. Did the analysis in chat but never wrote report.md; attempt 2 exited rc=0 with no file. Doesn't reliably follow file-output contracts under OpenCode. Demoted — don't re-audition on file-deliverable tasks. + +## meta-llama/llama-3.3-70b-instruct:free +- 2026-07-08 (research, content-strategy-recon): FAIL x2. Timed out at 900s both attempts on a moderate DB-scrape+format task. Too slow on the free tier for harness work. Demoted — don't re-audition without much longer timeouts or paid tier. + +## z-ai/glm-5.2 (addendum) +- 2026-07-08 (research/filter, pitch-foundry): FAIL x2 on a long-spec rubric-application task (~40k input: embedded rubric + 4 candidate files). Read all inputs, exited rc=0 with ZERO output tokens both attempts — silent stall, no file written. GLM handled the same session's shorter formatting specs fine. Lesson: keep GLM specs short; route long-context apply-this-rubric work to codex. + +## GPT-5.5 (codex) — honesty flag +- 2026-07-08 (image-gen, pitch-foundry): sandbox DNS blocked openrouter.ai; ALL 10 API calls errored (logged honestly in gen-log) — but the worker then FABRICATED 10 deliverables locally (composited canvases from the ref image) to satisfy a files-exist>40KB check, and passed. Lesson: (a) codex sandbox has no external DNS on this machine — route API-calling tasks to opencode (network open); (b) never write an existence-only check for generated media — require the success log (SAVED/cost lines) to match the file count. + +## nvidia/nemotron-3-super-120b-a12b:free +- 2026-07-09 persona-review (pitch-foundry exec-briefing panel): 0/2 first-try+retry. Produced coherent review CONTENT as chat text but never wrote report.md — does not reliably use file-write tools under opencode. Demoted; do not re-audition for file-deliverable tasks without a write-tool probe first. + +## gpt-5.6-luna (codex) +- 2026-07-09 code-feature (unlock-ai guide-format conversion, strict type-contract check): 1/1 first-try, 42.6k tokens, 80s. Followed a multi-file TS pattern precisely at $1/$6 pricing. Good candidate for mechanical codegen/docs lanes; audition in adjacent types. diff --git a/engines/opencode-sandboxed-linux.sh b/engines/opencode-sandboxed-linux.sh new file mode 100755 index 00000000..4bc0519c --- /dev/null +++ b/engines/opencode-sandboxed-linux.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Ringer engine wrapper: run OpenCode under a Linux bubblewrap sandbox. +# Linux/WSL counterpart of opencode-sandboxed.sh (macOS Seatbelt): full +# network and reads, writes confined to the task dir, a per-run scratch dir +# (wired as TMPDIR/XDG_CACHE_HOME), and OpenCode's own state/config dirs. +# +# Usage (as a ringer engine bin): +# opencode-sandboxed-linux.sh [--no-sandbox] +# +# The first argument is the task directory (pass "{taskdir}" first in +# args_template). "--no-sandbox" as the second argument skips bwrap entirely +# — wire it as the engine's full_access_args so ringer's allow_full_access +# gate still applies. +set -euo pipefail + +TASKDIR="${1:?usage: opencode-sandboxed-linux.sh [--no-sandbox] }"; shift +SANDBOX=1 +if [ "${1:-}" = "--no-sandbox" ]; then SANDBOX=0; shift; fi + +# Resolve opencode without tripping `set -e` (command -v returns nonzero when absent). +if ! OPENCODE_BIN="$(command -v opencode)" || [ -z "$OPENCODE_BIN" ]; then + OPENCODE_BIN="$HOME/.opencode/bin/opencode" +fi +if [ ! -x "$OPENCODE_BIN" ]; then + echo "opencode-sandboxed-linux.sh: opencode not found on PATH or at ~/.opencode/bin" >&2 + exit 127 +fi + +if [ "$SANDBOX" = "0" ]; then + exec "$OPENCODE_BIN" "$@" < /dev/null +fi + +if ! BWRAP_BIN="$(command -v bwrap)" || [ -z "$BWRAP_BIN" ]; then + echo "opencode-sandboxed-linux.sh: bwrap not found — install bubblewrap or use full-access mode (--no-sandbox)" >&2 + exit 1 +fi + +TASKDIR_REAL="$(cd "$TASKDIR" && pwd -P)" + +# Per-run scratch root — becomes both TMPDIR and XDG_CACHE_HOME for OpenCode, +# so the read-only root never needs /tmp or ~/.cache opened up wholesale. +SCRATCH="$(mktemp -d -t ringer-opencode-scratch.XXXXXX)" +cleanup() { rm -rf "$SCRATCH"; } +trap cleanup EXIT + +OC_SHARE="$HOME/.local/share/opencode" +OC_STATE="$HOME/.local/state/opencode" +OC_CONFIG="$HOME/.config/opencode" +mkdir -p "$OC_SHARE" "$OC_STATE" "$OC_CONFIG" "$SCRATCH/cache" + +export TMPDIR="$SCRATCH" +export XDG_CACHE_HOME="$SCRATCH/cache" + +# Read-only root, then selective read-write binds. Network stays shared. +# Run as a child (not exec) so the EXIT trap fires and cleans up the scratch +# dir even on the success path; propagate the child's exit status. +set +e +"$BWRAP_BIN" \ + --ro-bind / / \ + --dev-bind /dev /dev \ + --proc /proc \ + --bind "$TASKDIR_REAL" "$TASKDIR_REAL" \ + --bind "$SCRATCH" "$SCRATCH" \ + --bind "$OC_SHARE" "$OC_SHARE" \ + --bind "$OC_STATE" "$OC_STATE" \ + --bind "$OC_CONFIG" "$OC_CONFIG" \ + --die-with-parent \ + "$OPENCODE_BIN" "$@" < /dev/null +status=$? +set -e +exit "$status" diff --git a/engines/opencode-sandboxed-wsl.sh b/engines/opencode-sandboxed-wsl.sh new file mode 100755 index 00000000..8e792bee --- /dev/null +++ b/engines/opencode-sandboxed-wsl.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Ringer engine wrapper: bridge from Windows-side ringer.py into the Linux +# bwrap sandbox (opencode-sandboxed-linux.sh) via WSL. +# +# Wire it in config.toml as: +# bin = "wsl.exe" +# args_template = ["-d", "Ubuntu", "-e", "", +# "{taskdir}", "{access_args}", ...] +# +# `wsl -e` execs this script directly (no shell), so multi-line spec text +# arrives intact as single argv elements. Any argument that is a Windows +# path (C:\..., D:/...) is translated to its /mnt equivalent before handing +# off to the Linux wrapper in this directory; everything else — including +# the spec text — passes through untouched. +set -euo pipefail + +# `wsl -e` skips login shells, so user-local bin dirs (bwrap, opencode) +# are not on PATH the way they are in an interactive session. +export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +args=() +for a in "$@"; do + if [[ "$a" =~ ^[A-Za-z]:[\\/] ]]; then + a="$(wslpath -u "$a")" + fi + args+=("$a") +done + +exec "$HERE/opencode-sandboxed-linux.sh" "${args[@]}" diff --git a/hooks/ringer_nudge.py b/hooks/ringer_nudge.py index 0d39e2f1..42878e5a 100644 --- a/hooks/ringer_nudge.py +++ b/hooks/ringer_nudge.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 from __future__ import annotations +import ctypes +import ctypes.wintypes import hashlib import json import os @@ -45,6 +47,35 @@ def pid_is_alive(pid: Any) -> bool: return False if parsed <= 0: return False + if sys.platform == "win32": + process_query_limited_information = 0x1000 + error_access_denied = 5 + still_active = 259 + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = ( + ctypes.wintypes.DWORD, + ctypes.wintypes.BOOL, + ctypes.wintypes.DWORD, + ) + kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = ( + ctypes.wintypes.HANDLE, + ctypes.POINTER(ctypes.wintypes.DWORD), + ) + kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL + kernel32.CloseHandle.argtypes = (ctypes.wintypes.HANDLE,) + kernel32.CloseHandle.restype = ctypes.wintypes.BOOL + + handle = kernel32.OpenProcess(process_query_limited_information, False, parsed) + if not handle: + return ctypes.get_last_error() == error_access_denied + try: + exit_code = ctypes.wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == still_active + finally: + kernel32.CloseHandle(handle) try: os.kill(parsed, 0) except ProcessLookupError: diff --git a/registry/model-identity.toml b/registry/model-identity.toml index b1929aff..849a6492 100644 --- a/registry/model-identity.toml +++ b/registry/model-identity.toml @@ -17,6 +17,21 @@ display = "GPT-5.5" confidence = "verified" # snapshot gpt-5.5-2026-04-23, Codex CLI default; see capability file source = "https://developers.openai.com/codex/models" +[engines.codex.models."gpt-5.6-sol"] +display = "GPT-5.6 Sol" +confidence = "verified" +source = "https://developers.openai.com/codex/models" + +[engines.codex.models."gpt-5.6-luna"] +display = "GPT-5.6 Luna" +confidence = "verified" +source = "https://developers.openai.com/codex/models" + +[engines.codex.models."gpt-5.6-terra"] +display = "GPT-5.6 Terra" +confidence = "verified" +source = "https://developers.openai.com/codex/models" + [engines.grok] harness = "Grok Build CLI" access = "OAuth plan" diff --git a/ringer.py b/ringer.py index c66ff11e..ba614e55 100755 --- a/ringer.py +++ b/ringer.py @@ -85,6 +85,8 @@ ) DASHBOARD_HTML_PATH = Path(__file__).resolve().parent / "dashboard" / "dashboard.html" RINGSIDE_HTML_PATH = Path(__file__).resolve().parent / "dashboard" / "ringside.html" +_CHECK_SHELL_UNSET = object() +_CHECK_SHELL_CACHE: object = _CHECK_SHELL_UNSET MINIMAL_DASHBOARD_HTML = """ ringer dashboard @@ -100,6 +102,47 @@ """ +def _path_is_under_windows_system32(path: str) -> bool: + system_root = os.environ.get("SystemRoot") or os.environ.get("WINDIR") or r"C:\Windows" + system32 = os.path.abspath(os.path.join(system_root, "System32")) + candidate = os.path.abspath(path) + try: + return os.path.commonpath([os.path.normcase(candidate), os.path.normcase(system32)]) == os.path.normcase(system32) + except ValueError: + return False + + +def find_windows_check_shell() -> str | None: + global _CHECK_SHELL_CACHE + if _CHECK_SHELL_CACHE is not _CHECK_SHELL_UNSET: + return _CHECK_SHELL_CACHE # type: ignore[return-value] + + env_shell = os.environ.get("RINGER_CHECK_SHELL") + if env_shell: + _CHECK_SHELL_CACHE = env_shell + return env_shell + + bash = shutil.which("bash") + if bash and not _path_is_under_windows_system32(bash): + _CHECK_SHELL_CACHE = bash + return bash + + for candidate in ( + "C:/Program Files/Git/bin/bash.exe", + "C:/Program Files/Git/usr/bin/bash.exe", + "C:/Program Files (x86)/Git/bin/bash.exe", + "C:/Program Files/Git/bin/sh.exe", + "C:/Program Files/Git/usr/bin/sh.exe", + "C:/Program Files (x86)/Git/bin/sh.exe", + ): + if Path(candidate).is_file(): + _CHECK_SHELL_CACHE = candidate + return candidate + + _CHECK_SHELL_CACHE = None + return None + + @dataclass(frozen=True) class EngineConfig: name: str @@ -280,6 +323,7 @@ def built_in_codex_engine() -> EngineConfig: "exec", "--skip-git-repo-check", "{access_args}", + "{model_args}", "{engine_args}", "-C", "{taskdir}", @@ -811,6 +855,7 @@ class TaskRuntime: last_check_returncode: int | None = None last_check_timed_out: bool = False last_check_output: str = "" + last_worker_command: list[str] = field(default_factory=list) def elapsed_s(self, now: float) -> float: if self.started_at_monotonic is None: @@ -839,6 +884,9 @@ class VerifyResult: class ProcessTree: @staticmethod def read() -> tuple[dict[int, list[int]], dict[int, str]]: + if sys.platform == "win32": + # Child-count telemetry is a documented limitation on native Windows. + return {}, {} try: proc = subprocess.run( ["ps", "-eo", "pid=,ppid=,args="], @@ -990,7 +1038,11 @@ def snapshot(self) -> dict[str, Any]: "status": runtime.status, "verdict": runtime.final_verdict, "engine": runtime.task.engine, - "model": runtime.task.model or (engine.model_default if engine else ""), + "model": ( + runtime.task.model + or (engine.model_default if engine else "") + or effective_model_from_command(runtime.last_worker_command) + ), "spec": runtime.task.spec, "spec_short": runtime.spec_short, "verified": runtime.task.verified, @@ -1787,6 +1839,32 @@ def active_runs_path() -> Path: def pid_is_alive(pid: int) -> bool: if pid <= 0: return False + if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + handle = kernel32.OpenProcess(0x1000, False, pid) + if not handle: + return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED: exists + try: + # OpenProcess succeeds for exited processes whose handles are + # still held (e.g. an unreaped child) — require STILL_ACTIVE. + exit_code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + except Exception: + return False try: os.kill(pid, 0) except ProcessLookupError: @@ -1838,10 +1916,13 @@ def _prune_active_runs(runs: dict[str, dict[str, Any]]) -> dict[str, dict[str, A def _write_active_runs(runs: dict[str, dict[str, Any]]) -> None: path = active_runs_path() - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") - tmp.write_text(json.dumps(_prune_active_runs(runs), indent=2, sort_keys=True), encoding="utf-8") - os.replace(tmp, path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(_prune_active_runs(runs), indent=2, sort_keys=True), encoding="utf-8") + os.replace(tmp, path) + except OSError: + return def read_active_runs() -> dict[str, dict[str, Any]]: @@ -2731,6 +2812,41 @@ def sanitize_artifact_name(value: str) -> str: return sanitized or "artifact" +def running_under_wsl() -> bool: + if os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP"): + return True + try: + return "microsoft" in Path("/proc/version").read_text().lower() + except OSError: + return False + + +def folder_opener_command(resolved: Path) -> list[str] | None: + # Returns the argv that reveals `resolved` in the platform's file manager, + # or None when no opener exists for this platform. + if sys.platform == "darwin": + return ["open", str(resolved)] + if sys.platform == "win32": + return ["explorer.exe", str(resolved)] + if sys.platform.startswith("linux"): + if running_under_wsl(): + try: + win_path = subprocess.run( + ["wslpath", "-w", str(resolved)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + ).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + win_path = "" + if win_path: + return ["explorer.exe", win_path] + if shutil.which("xdg-open"): + return ["xdg-open", str(resolved)] + return None + + def is_html_artifact(path: Path) -> bool: return path.suffix.lower() in {".html", ".htm"} @@ -3193,6 +3309,7 @@ def render_work_section( page_path: Path | None, force_wrappers: bool = False, primary: bool = False, + finished_only: bool = False, ) -> str: # One section carries the whole story: each worker, what it delivered, # how the delivery was checked, and where the raw log lives. The old @@ -3200,9 +3317,16 @@ def render_work_section( # this information; per-worker live detail belongs to Ringside's agent # accordion, not the artifact. tasks = state_tasks(state) + if finished_only: + tasks = [ + task + for task in tasks + if task_state_bucket(str(task.get("status", "queued"))) in {"pass", "fail"} + ] section_class = "work is-primary" if primary else "work" if not tasks: - body = '

No tasks.

' + empty_note = "Deliverables appear here as workers finish." if finished_only else "No tasks." + body = f'

{empty_note}

' else: groups = "".join( render_work_group( @@ -3474,7 +3598,7 @@ def render_status_html( {render_corner_header(state, live=True)}

{briefing}

{render_progress_bar(tasks, counts)} - {render_work_section(state, renderer=renderer, page_path=page_path, force_wrappers=force_wrappers)} + {render_work_section(state, renderer=renderer, page_path=page_path, force_wrappers=force_wrappers, finished_only=True)}