Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- Durable permission decisions render through `components/permission-receipts.tsx` outside native tool visibility, with bounded mounted-row reads and a paginated session disclosure for unanchored receipts. Keep receipts out of native message/copy/search/speech projections; styles live in `styles/messaging/permission-receipts.css`.
- Completed question receipts share native answer decoding with text projection through `tool-call/renderers/question-data.ts`. Preserve selected-option descriptions and verbatim free text; keep remaining choices in a native disclosure. Receipt styles live in `styles/messaging/tool-call/question.css`.
- Pending-request recovery uses the authenticated broker in `packages/server/src/server/routes/pending-requests.ts` when the daemon supports loaded-only snapshots. Validate directory ownership before querying and every returned placement before publishing; retain execution-host authority even for cold/empty coverage, including WSL and path aliases. Preserve idle/global Forms, per-kind mutation fences and settled-request tombstones. Errors or incomplete coverage never clear queues. The capability is negotiated per connection, not inferred from a version label; older daemons retain their existing discovery behavior and do not gain the native performance fix merely by updating CodeNomad.
- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`.
- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Troubleshooting always exposes an explicit, confirmed shared-service restart independently of updates; retain the inline activation shortcut after an update, and never downgrade a newer daemon through an older selected CLI. Configuration reload remains a separate process-preserving action. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`.
- Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics.
- Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints.
- Composer context selectors and actions always share one row on desktop and touch; truncate selector labels rather than wrapping the footer. Dense conversation toolbar/footer controls use `--touch-target-size-compact`; timeline markers use the compact control height rather than the global touch target. Toolbar touch spacing lives in `styles/panels/session-toolbar-touch.css`. Transcript filters join the header action overflow and anchor/focus back to that menu when collapsed.
Expand Down
13 changes: 13 additions & 0 deletions dev-docs/OPENCODE_V2_POST_BETA.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ non-disruptive reconnect updates backend executable ownership so opening additio
workspaces remains possible while restart is deferred. Executable selection and
workspace execution-host eligibility are rechecked before service mutation.

Troubleshooting also keeps **Restart OpenCode service** available without an
installation or version change. The existing lifecycle may replace an authenticated
same-version daemon, but an older selected CLI still cannot replace a newer daemon
and unknown versions remain fail-closed. The control remains visible but disabled
when restart authority is unavailable or a fresh status check fails. It shares the
host/WSL service path and serialization with update activation; opening settings,
checking status, reconnecting and reloading configuration never restart implicitly.
The troubleshooting action confirms the all-client interruption, fences a changed
executable or unmounted view, and does not resume a pending workspace-open callback.
The inline post-update activation shortcut remains separate. Session history is not
deleted by either action; configuration reload preserves the daemon process and
must not be presented as a process restart or a guarantee of memory reclamation.

Mounted Windows discovery directories and Linux aliases are canonicalized through
the selected distro, then translated for host filesystem access. Native import
URLs and lease paths stay Linux-native; canonical outside-root storage is retained.
Expand Down
86 changes: 85 additions & 1 deletion packages/server/src/opencode-update/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ test("an unverified newer major is not blocked but cannot be downgraded by resta
assert.equal((await service.getStatus()).canRestart, false)
assert.equal((await service.start()).serviceState, "ready")
assert.equal((await service.getStatus()).versionAssessment, "untested")
await assert.rejects(service.start(true), /not an older runtime/)
await assert.rejects(service.start(true), /cannot safely replace/)
})

test("optional upgrades retain explicit activation for an admitted but older daemon", async () => {
Expand All @@ -214,6 +214,90 @@ test("optional upgrades retain explicit activation for an admitted but older dae
assert.equal(restarts, 1)
})

test("same-version troubleshooting restart is explicit for host and WSL; reload never restarts", async () => {
for (const binaryPath of ["/fixture/opencode", "\\\\wsl.localhost\\Ubuntu\\usr\\bin\\opencode"]) {
let pid = 123, restarts = 0, reloads = 0, reconnects = 0
const endpoint = () => {
const value: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(value, { version: "2.0.22", pid, discovery: "info" })
return value
}
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: binaryPath, label: "Fixture" }),
probeBinary: () => ({ valid: true, version: "2.0.22" }), resolveLatestVersion: async () => "2.0.22",
lifecycle: async () => ({ discover: async () => endpoint(), ensure: async () => { assert.fail("retain the running daemon") },
restart: async () => { restarts++; pid++; return endpoint() } }),
upgradeBinary: async () => { assert.fail("restart must not install a version") },
reload: async () => { reloads++ }, reconnect: async () => { reconnects++ },
}))
const status = await service.getStatus()
assert.equal(status.serviceState, "ready")
assert.equal(status.updateAvailable, false)
assert.equal(status.canRestart, true)
assert.equal(status.canReload, true)
await service.start()
await service.reload()
assert.equal(restarts, 0)
assert.equal(pid, 123, "configuration reload preserves the process")
assert.equal(reloads, 1)
const restarted = await service.start(true)
assert.equal(restarts, 1)
assert.equal(pid, 124)
assert.equal(reconnects, 2)
assert.equal(restarted.serviceState, "ready")
assert.equal(restarted.canRestart, true, "troubleshooting stays available after a restart")
}
})

test("troubleshooting restart refuses unknown identity/builds and a newer daemon", async () => {
for (const version of [undefined, "custom-build", "2.0.23"]) {
const endpoint: Endpoint = { url: "http://127.0.0.1:9876" }
if (version) rememberRuntime(endpoint, { version, pid: 123, discovery: "info" })
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: "2.0.22" }),
lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") },
restart: async () => { assert.fail("must not stop an unverified or newer daemon") } }),
}))
assert.equal((await service.getStatus()).canRestart, false)
await assert.rejects(service.start(true), /cannot safely replace/)
}
})

test("same-version restart coalesces, excludes reload, and never retries a failed mutation", async () => {
let restarts = 0, reconnects = 0
let release!: () => void, entered!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const reached = new Promise<void>(resolve => { entered = resolve })
const endpoint: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(endpoint, { version: "2.0.22", pid: 123, discovery: "info" })
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: "2.0.22" }),
lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") },
restart: async () => { restarts++; entered(); await gate; throw new Error("restart failed") } }),
reconnect: async () => { reconnects++ },
}))
const first = service.start(true)
assert.equal(service.start(true), first)
await reached
await assert.rejects(service.reload(), /action is in progress/)
assert.equal(restarts, 1)
release()
await assert.rejects(first, /restart failed/)
assert.equal(restarts, 1)
assert.equal(reconnects, 0)
})

test("a lifecycle without restart cannot expose or silently emulate it", async () => {
const endpoint: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(endpoint, { version: "2.0.22", pid: 123, discovery: "info" })
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: "2.0.22" }),
lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") } }),
}))
assert.equal((await service.getStatus()).canRestart, false)
await assert.rejects(service.start(true), /restart unavailable/)
})

test("explicit configuration reload is admitted, fenced and serialized with service actions", async () => {
let selected = "/fixture/opencode", version = "2.0.11", reloads = 0
let release!: () => void
Expand Down
16 changes: 12 additions & 4 deletions packages/server/src/opencode-update/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ export class OpenCodeUpdateService {
}
}
status.canRestart = Boolean(lifecycle.restart) && state === "ready"
&& (status.serviceState === "restart_required" || status.serviceState === "restart_available")
&& (status.serviceState === "ready" || status.serviceState === "restart_required" || status.serviceState === "restart_available")
&& canReplaceDaemon(currentVersion, identity?.version)
} catch { status.serviceState = "error"; status.serviceError = "service_check_failed" }
}
const effectiveVersion = status.daemonVersion ?? currentVersion
Expand Down Expand Up @@ -148,13 +149,13 @@ export class OpenCodeUpdateService {
const installedVersion = await this.readCurrentVersion(binary.path)
const lifecycle = await this.deps.lifecycle?.(binary)
if (!lifecycle) throw new Error("OpenCode service lifecycle unavailable")
if (restart && !lifecycle.restart) throw new Error("OpenCode service restart unavailable")
const previous = await lifecycle.discover()
if (!previous || restart) assertSupportedOpenCode(installedVersion)
if (restart && previous) {
const identity = runtimeIdentity(previous)
if (!identity || !comparableVersion(identity.version) || !comparableVersion(installedVersion)
|| compareOpenCodeVersionStrings(installedVersion, identity.version) <= 0) {
throw new Error("The shared daemon is not an older runtime eligible for this update")
if (!canReplaceDaemon(installedVersion, identity?.version)) {
throw new Error("The selected OpenCode executable cannot safely replace the running daemon")
}
}
const assertCurrent = () => {
Expand Down Expand Up @@ -320,6 +321,13 @@ export function createOpenCodeUpdateService(
})
}

// Explicit troubleshooting can replace the same release, but must retain the
// update path's authenticated-version fence against downgrades/unknown builds.
function canReplaceDaemon(installed: string | null, running?: string): boolean {
return Boolean(installed && running && comparableVersion(installed) && comparableVersion(running)
&& compareOpenCodeVersionStrings(installed, running) >= 0)
}

function comparableVersion(version: string): boolean {
return /^\d+\.\d+\.\d+$/.test(version) || /^0\.0\.0-beta-\d+$/.test(version)
}
Expand Down
39 changes: 31 additions & 8 deletions packages/ui/src/components/settings/opencode-setup-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Show, type ParentProps } from "solid-js"
import { createSignal, onCleanup, Show, type ParentProps } from "solid-js"
import { useI18n } from "../../lib/i18n"
import { showConfirmDialog } from "../../stores/alerts"
import { serverSettings } from "../../stores/preferences"
import { OpenCodeSetupProgress } from "./opencode-setup-progress"
import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError, openCodeSetupCheckError,
openCodeSetupChecking as checking, openCodeSetupAction as action, openCodeSetupFeedback, openCodeInstallationError,
Expand All @@ -8,7 +10,26 @@ import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetup

export function OpenCodeSetupPanel(props: ParentProps = {}) {
const { t } = useI18n()
const disabled = () => busy() || checking()
const [restartPending, setRestartPending] = createSignal(false)
let disposed = false
onCleanup(() => { disposed = true })
const disabled = () => busy() || checking() || restartPending()
const restartForTroubleshooting = async () => {
if (disabled() || openCodeSetupCheckError() || !status()?.canRestart) return
const binary = serverSettings().opencodeBinary
setRestartPending(true)
try {
const confirmed = await showConfirmDialog(t("settings.opencode.setup.restartDescription"), {
title: t("settings.opencode.setup.restartTroubleshooting"), variant: "warning",
confirmLabel: t("settings.opencode.setup.restartTroubleshooting"), cancelLabel: t("alertDialog.actions.cancel"),
})
if (!confirmed || disposed || binary !== serverSettings().opencodeBinary || busy() || checking()
|| openCodeSetupCheckError() || !status()?.canRestart) return
await runOpenCodeSetup("restart", { resumeWorkspace: false })
} finally {
if (!disposed) setRestartPending(false)
}
}
const progress = () => checking() ? "settings.opencode.update.checking"
: action() === "install" ? "settings.opencode.update.updating"
: action() ? `settings.opencode.setup.progress.${action()}` : undefined
Expand Down Expand Up @@ -53,7 +74,7 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {
<Show when={isOpenCodeConnected() && canContinueOpenCodeSetup()}>
<button type="button" class="settings-pill-button" disabled={disabled()} onClick={() => void continueOpenCodeSetup()}>{t("settings.opencode.setup.continue")}</button>
</Show>
<Show when={data().canRestart}><button type="button" class="settings-pill-button" disabled={disabled() || openCodeSetupCheckError()} onClick={() => void runOpenCodeSetup("restart")}>{t("settings.opencode.setup.restart")}</button></Show>
<Show when={data().canRestart && (data().serviceState === "restart_required" || data().serviceState === "restart_available")}><button type="button" class="settings-pill-button" disabled={disabled() || openCodeSetupCheckError()} onClick={() => void runOpenCodeSetup("restart")}>{t("settings.opencode.setup.restart")}</button></Show>
</div>
<Show when={data().canUpgrade && (data().state === "missing" || data().needsSharedInstallation)}>
<p class="settings-toggle-caption">{t("settings.opencode.setup.sharedInstallDescription")}</p>
Expand All @@ -80,13 +101,15 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {
<p role="alert">{t(`settings.opencode.setup.${data().incompatibilityReason}`)}</p>
</Show>
</details>
<Show when={data().canReload}>
<details class="opencode-setup-details">
<summary>{t("settings.opencode.setup.troubleshooting")}</summary>
<details class="opencode-setup-details">
<summary>{t("settings.opencode.setup.troubleshooting")}</summary>
<p class="settings-toggle-caption">{t("settings.opencode.setup.restartDescription")}</p>
<div class="settings-info-actions"><button type="button" class="settings-pill-button" disabled={disabled() || openCodeSetupCheckError() || !data().canRestart} onClick={() => void restartForTroubleshooting()}>{t("settings.opencode.setup.restartTroubleshooting")}</button></div>
<Show when={data().canReload}>
<p class="settings-toggle-caption">{t("settings.opencode.setup.reloadDescription")}</p>
<div class="settings-info-actions"><button type="button" class="settings-pill-button" disabled={disabled() || openCodeSetupCheckError()} onClick={() => void runOpenCodeSetup("reload")}>{t("settings.opencode.setup.reload")}</button></div>
</details>
</Show>
</Show>
</details>
</>}
</Show>
</div></>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/de/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const settingsMessages = {
"settings.opencode.setup.continue": "Weiter",
"settings.opencode.setup.diagnostics": "Versionsdetails",
"settings.opencode.setup.troubleshooting": "Fehlerbehebung",
"settings.opencode.setup.restartTroubleshooting": "OpenCode-Dienst neu starten",
"settings.opencode.setup.restartDescription": "Starten Sie den OpenCode-Prozess neu, ohne ein Update zu installieren. Anders als beim Neuladen der Konfiguration wird der Prozess ersetzt und aktive Arbeit in allen verbundenen Clients unterbrochen. Der Sitzungsverlauf bleibt erhalten.",
"settings.opencode.setup.check": "Status und Updates prüfen",
"settings.opencode.setup.checked": "Status- und Updateprüfung abgeschlossen.",
"settings.opencode.setup.reloaded": "OpenCode-Konfiguration neu geladen.",
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const settingsMessages = {
"settings.opencode.setup.continue": "Continue",
"settings.opencode.setup.diagnostics": "Version details",
"settings.opencode.setup.troubleshooting": "Troubleshooting",
"settings.opencode.setup.restartTroubleshooting": "Restart OpenCode service",
"settings.opencode.setup.restartDescription": "Restart the OpenCode process without installing an update. Unlike configuration reload, this replaces the process and interrupts active work in all connected clients. Session history is kept.",
"settings.opencode.setup.check": "Check status and updates",
"settings.opencode.setup.checked": "Status and update check complete.",
"settings.opencode.setup.reloaded": "OpenCode configuration reloaded.",
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/es/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const settingsMessages = {
"settings.opencode.setup.continue": "Continuar",
"settings.opencode.setup.diagnostics": "Detalles de versiones",
"settings.opencode.setup.troubleshooting": "Solución de problemas",
"settings.opencode.setup.restartTroubleshooting": "Reiniciar servicio OpenCode",
"settings.opencode.setup.restartDescription": "Reinicia el proceso OpenCode sin instalar una actualización. A diferencia de recargar la configuración, esto reemplaza el proceso e interrumpe el trabajo activo de todos los clientes conectados. El historial de sesiones se conserva.",
"settings.opencode.setup.check": "Comprobar estado y actualizaciones",
"settings.opencode.setup.checked": "Comprobación de estado y actualizaciones completada.",
"settings.opencode.setup.reloaded": "Configuración de OpenCode recargada.",
Expand Down
Loading
Loading