Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
ed4c832
wip(app): extension host, sdk, and desktop main host
Hona Sep 30, 2026
10a3678
wip(app): generic side, dock, and mobile regions
Hona Sep 30, 2026
665e7c2
wip(app): shell bottom slot, channel status, builtin manifests
Hona Sep 30, 2026
c61a994
wip: browser contract, system open for file urls
Hona Sep 30, 2026
b31f579
feat(app): move usage, btw, and debug into gui extensions
Hona Sep 30, 2026
96d5666
feat(app): move the terminal into a gui extension
Hona Sep 30, 2026
55a196e
chore: lint gui extension boundaries and module state
Hona Sep 30, 2026
ce2585b
feat(desktop): move the browser pane into a gui extension
Hona Sep 30, 2026
8bbd280
feat(desktop): move the updater and pairing into gui extensions
Hona Sep 30, 2026
6eb9d3f
feat(app): move the file browser and review into gui extensions
Hona Sep 30, 2026
4fc739f
feat(desktop): move ssh and wsl servers into gui extensions
Hona Sep 30, 2026
49f1837
feat(app): host renderers for settings, status, servers, surfaces, an…
Hona Sep 30, 2026
f5bc705
fix(app): tab selection guard, host project picker, early main extens…
Hona Sep 30, 2026
adf0423
feat(app): move the session summary into a gui extension
Hona Sep 30, 2026
7c2235e
refactor(app): move extension translations into extension folders
Hona Sep 30, 2026
ff4a8e6
fix(app): keep the app mounted when an extension is enabled
Hona Sep 30, 2026
78f878c
fix(app): import extension storage from the default storage too
Hona Sep 30, 2026
ab14739
fix(app): close extension dialogs by id and stop hidden summary loads
Hona Sep 30, 2026
6154dc8
test(desktop): prune the desktop test surface
Hona Sep 30, 2026
8b7f10b
fix(ui): dispose each closing dialog on its own timer
Hona Sep 30, 2026
2c8a70c
fix(ui): guard dialog closes and derive layers from the stack
Hona Sep 30, 2026
8dcbae7
test(desktop): restore contracts the prune dropped
Hona Sep 30, 2026
94bb7f2
fix(ui): harden dialog teardown, errors and focus during exits
Hona Sep 30, 2026
35b14b3
fix(ui): tell replaced dialogs they closed; polyfill Promise.try
Hona Sep 30, 2026
3b0481e
refactor(app): leave app and desktop clean after the extension move
Hona Sep 30, 2026
33da15a
test(app): shared e2e harness for the test campaign
Hona Sep 30, 2026
ed33675
docs: test ownership rules for app, desktop and gui extensions
Hona Sep 30, 2026
cc95fd3
fix(app): normalize stored panel tab ids through the owning extension
Hona Sep 30, 2026
da7759d
fix(app): remap stored tabs, selection and preview together
Hona Sep 30, 2026
3978a5e
test(session-ui): run component tests in CI and bring them current
Hona Sep 30, 2026
37456ad
fix(app): keep a tab permanent when a preview collapses into it
Hona Sep 30, 2026
3033748
test(app): tighten the e2e harness mocks
Hona Sep 30, 2026
7da3c91
test(app): prune the composer and command test surface
Hona Sep 30, 2026
8dba9ae
fix(app): name the session's server in the summary when several exist
Hona Sep 30, 2026
b07e633
test(app): prune the gui extension test surface
Hona Sep 30, 2026
a8d4d1e
test(app): scope mock PTYs by workspace and issue unique tickets
Hona Sep 30, 2026
a242629
test(app): restore composer contracts the prune dropped
Hona Sep 30, 2026
85ba40f
test(app): restore extension contracts the prune dropped
Hona Sep 30, 2026
fd2537f
test(app): prune the shell, runtime and settings test surface
Hona Sep 30, 2026
04f8481
test(app): prune the session timeline test surface
Hona Sep 30, 2026
8a37e31
test(app): restore shell contracts the prune dropped
Hona Sep 30, 2026
f478d67
test(app): restore timeline contracts the prune dropped
Hona Sep 30, 2026
86cd53d
test(app): finish the shared e2e harness
Hona Sep 30, 2026
e649c08
test(app): answer the review pane benchmark in the server's diff shape
Hona Sep 30, 2026
c82606b
perf(app): compile extension chunks while the app idles
Hona Sep 30, 2026
2f8dfa1
perf(review): reuse the model's renderable diffs and kinds in the panel
Hona Sep 30, 2026
3ec96fb
perf(review): keep the tree still when a session switch reopens revie…
Hona Sep 30, 2026
6bca5b3
test(app): fail unmocked API requests and tighten harness waits
Hona Sep 30, 2026
6cf58d9
test(app): page mock shell output like the server and acknowledge fin…
Hona Sep 30, 2026
acc9415
refactor(util): share file URL encoding and file kinds
Hona Sep 30, 2026
2fe6172
Merge upstream/v2 into gui-extensions
Hona Sep 30, 2026
6ce809a
chore(app): merge duplicate util path imports
Hona Sep 30, 2026
84e0bc0
fix(app): keep browser comments from pre-extension builds
Hona Sep 30, 2026
afddc7c
test(browser): restore the browser pane's tests
Hona Sep 30, 2026
da3a28c
fix(browser): keep element refs unique across reloads and notes editable
Hona Sep 30, 2026
c84e9b4
test(browser): deliver the stale event in the suspension case
Hona Sep 30, 2026
7ab3087
fix(storybook): restore every designer story with its id
Hona Sep 30, 2026
120f801
fix(storybook): run the real SSH renderer in its stories
Hona Sep 30, 2026
0cee67e
fix(app): stop activating an extension after the host unmounts
Hona Sep 30, 2026
8726611
fix(app): run cleanups an extension registers after it is disposed
Hona Sep 30, 2026
d0f249e
style: format files the extension move added
Hona Sep 30, 2026
5eff053
chore(lint): vendor anti-slop Oxlint rules for the GUI packages
Hona Sep 30, 2026
e2c5866
fix(desktop): refuse the extension manager in packaged builds
Hona Sep 30, 2026
4a27b0b
docs(desktop): mark archive installs as groundwork for .ocdx extensions
Hona Sep 30, 2026
7b8f423
fix(review): keep each session's review state across the upgrade
Hona Sep 30, 2026
4942243
fix(extensions): make reload, disable and dialogs safe mid-activation
Hona Sep 30, 2026
fc1ac2a
fix(extensions): finish teardown before a replacement activates
Hona Oct 1, 2026
a4649b5
fix(app): follow a restarted server's live controller
Hona Oct 1, 2026
64f533f
refactor(desktop): serialize each extension's lifecycle
Hona Oct 1, 2026
85118c8
fix(app): remount a server's routes when sign-in replaces its controller
Hona Oct 1, 2026
b0e8f7b
fix(browser): re-register panes when the browser remote returns
Hona Oct 1, 2026
f0a9e22
fix(app): keep a disposed extension's queued dialog closed
Hona Oct 1, 2026
46409aa
fix(browser): wait for every page to close before the pane settles
Hona Oct 1, 2026
f630927
fix(app): let remote events win over older snapshots
Hona Oct 1, 2026
f3bc1e3
test(app): let the extension host fixture disable and reload
Hona Oct 1, 2026
9e34a23
fix(browser): retry a rejected pane registration
Hona Oct 1, 2026
c37c70d
fix(app): keep the app mounted through extension server reloads
Hona Oct 1, 2026
85924c5
fix(wsl): stop starting servers on teardown
Hona Oct 1, 2026
74555b9
fix(app): keep extension servers' tabs through an outage
Hona Oct 1, 2026
e24dc88
fix(summary): show the global-sync project like v2
Hona Oct 1, 2026
5ae5ded
fix(app): close the details drawer from its changes row like v2
Hona Oct 1, 2026
9dd0186
fix(app): restore v2's Context tab order and saved tree tab
Hona Oct 1, 2026
39910ec
fix(app): realign native desktop labels in hr, hu, is and lt
Hona Oct 1, 2026
fdd5cc3
fix(app): restore v2's side region opener and mobile view reset
Hona Oct 1, 2026
62a8129
fix(app): restore v2's mobile changes, browser pruning and file heading
Hona Oct 1, 2026
dab184a
fix(btw): drop a /btw tab saved before extensions
Hona Oct 1, 2026
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
Prev Previous commit
Next Next commit
fix(wsl): stop starting servers on teardown
Teardown cleared startup tokens but only awaited sidecars that had finished starting, so a reload could start a replacement beside the old process and disable or quit could leave a WSL server running. Each start now carries an abort handle: teardown cancels CLI discovery and health polling, kills a spawned server, and resolves only after it exits. A cancelled start shows the server as stopped.
  • Loading branch information
Hona committed Oct 1, 2026
commit 85924c51cadbdc4e26e034fc3bc134e2aeb7b19d
3 changes: 2 additions & 1 deletion packages/gui-extensions/src/wsl/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ const setup: Setup = (ctx) => {
await runtime.installCli(distro, { version: cli.version, binary })
}
: (distro, build) => runtime.installCli(distro, build),
spawnSidecar: (distro) => {
spawnSidecar: (distro, signal) => {
log("info", "spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
runtime,
t,
packaged,
signal,
onLine: (line) => log("info", "wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
Expand Down
39 changes: 39 additions & 0 deletions packages/gui-extensions/src/wsl/servers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from "bun:test"
import { spawn, type ChildProcess } from "node:child_process"
import type { WslServerConfig } from "./contract"
import type { WslRuntime } from "./runtime"
import { createWslServersController } from "./servers"

test("teardown during a pending start resolves only after the started process exited", async () => {
const children: ChildProcess[] = []
const saved: WslServerConfig[] = []
const controller = createWslServersController({
cli: { version: "1.0.0" },
runtime: {} as WslRuntime,
t: (key) => key,
log: () => undefined,
installCli: async () => undefined,
readServers: () => saved,
writeServers: (servers) => saved.splice(0, saved.length, ...servers),
// A real server process that never turns healthy; an abort ends it and rejects once it exited.
spawnSidecar: (_distro, signal) => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" })
children.push(child)
return new Promise((_, reject) =>
signal.addEventListener(
"abort",
() => {
child.once("exit", () => reject(signal.reason))
child.kill()
},
{ once: true },
),
)
},
})
await controller.addServer("Debian")
while (!children.length) await Bun.sleep(1)
expect(children[0].exitCode).toBeNull()
await controller.stopServers()
expect(children[0].exitCode !== null || children[0].signalCode !== null).toBe(true)
})
49 changes: 33 additions & 16 deletions packages/gui-extensions/src/wsl/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ type WslServersControllerOptions = {
runtime: WslRuntime
t: Context["t"]
log: (level: "info" | "error", message: string, data: Record<string, unknown>) => void
spawnSidecar: (distro: string) => Promise<RunningSidecar>
/** Rejects once signal aborts, after any process it started has exited. */
spawnSidecar: (distro: string, signal: AbortSignal) => Promise<RunningSidecar>
installCli: (distro: string, cli: WslCliBuild) => Promise<void>
readServers: () => WslServerConfig[]
writeServers: (servers: WslServerConfig[]) => void
Expand All @@ -40,7 +41,9 @@ export function createWslServersController(options: WslServersControllerOptions)
let state: WslServersState = initialState()
const listeners = new Set<() => void>()
const sidecars = new Map<string, RunningSidecar>()
const starts = new Map<string, symbol>()
// Starts in flight. Stopping one aborts its CLI discovery and health polling and waits until its process exited,
// so a restart, reload, disable or quit never leaves a sidecar running beside or after it.
const starts = new Map<string, { readonly abort: AbortController; readonly done: Promise<void> }>()
let closed = false

const emit = () => {
Expand Down Expand Up @@ -137,13 +140,20 @@ export function createWslServersController(options: WslServersControllerOptions)
if (!item) return
await stopServer(id)
if (closed) return
const token = Symbol()
starts.set(id, token)
const abort = new AbortController()
const startup = { abort, done: launch(id, item.config.distro, abort) }
starts.set(id, startup)
await startup.done
}

const launch = async (id: string, distro: string, abort: AbortController) => {
// A later start or a stop replaced this one.
const current = () => starts.get(id)?.abort === abort
setRuntime(id, { kind: "starting" })
options.log("info", "wsl sidecar starting", { id, distro: item.config.distro })
options.log("info", "wsl sidecar starting", { id, distro })
try {
const sidecar = await options.spawnSidecar(item.config.distro)
if (starts.get(id) !== token) {
const sidecar = await options.spawnSidecar(distro, abort.signal)
if (!current()) {
await sidecar.stop()
return
}
Expand All @@ -159,26 +169,28 @@ export function createWslServersController(options: WslServersControllerOptions)
sidecars.delete(id)
const message = t("error.serverExited", { code: code ?? "null", signal: signal ?? "null" })
setRuntime(id, { kind: "failed", message })
options.log("error", "wsl sidecar exited", { id, distro: item.config.distro, code, signal })
options.log("error", "wsl sidecar exited", { id, distro, code, signal })
})
void refreshCliCheckSafely(id, item.config.distro)
options.log("info", "wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
void refreshCliCheckSafely(id, distro)
options.log("info", "wsl sidecar ready", { id, distro, url: sidecar.url })
} catch (error) {
if (starts.get(id) !== token) return
if (!current()) return
starts.delete(id)
const message = error instanceof Error ? error.message : String(error)
setRuntime(id, { kind: "failed", message })
options.log("error", "wsl sidecar failed to start", { id, distro: item.config.distro, message })
options.log("error", "wsl sidecar failed to start", { id, distro, message })
}
}

const stopServer = async (id: string) => {
const startup = starts.get(id)
starts.delete(id)
startup?.abort.abort()
await startup?.done
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
await existing.stop()
setRuntime(id, { kind: "stopped" })
await existing?.stop()
if (startup || existing) setRuntime(id, { kind: "stopped" })
}

const runJob = async <T>(job: WslJob, runner: () => Promise<T>) => {
Expand Down Expand Up @@ -291,8 +303,13 @@ export function createWslServersController(options: WslServersControllerOptions)

async stopServers() {
closed = true
const pending = [...starts.values()]
starts.clear()
await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop()))
pending.forEach((startup) => startup.abort.abort())
await Promise.all([
...pending.map((startup) => startup.done),
...[...sidecars.values()].map((sidecar) => sidecar.stop()),
])
sidecars.clear()
},
}
Expand Down
34 changes: 22 additions & 12 deletions packages/gui-extensions/src/wsl/sidecar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process"
import { spawn, type ChildProcess } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import type { Context } from "../sdk/main"
Expand All @@ -17,15 +17,18 @@ export async function spawnWslSidecar(
runtime: WslRuntime
t: Context["t"]
packaged: boolean
/** Aborts the start: CLI discovery, health polling, and the server process, which has exited when this rejects. */
signal: AbortSignal
onLine?: (line: WslCommandLine) => void
healthTimeoutMs?: number
},
): Promise<WslSidecar> {
const t = opts.t
const opencode = await opts.runtime.resolveCli(distro)
const opencode = await opts.runtime.resolveCli(distro, { signal: opts.signal })
if (!opencode) throw new Error(t("error.opencodeNotInstalled", { distro }))

const port = await allocatePort(t)
opts.signal.throwIfAborted()
const password = randomUUID()
const script = [
"set -euo pipefail",
Expand Down Expand Up @@ -69,6 +72,9 @@ export async function spawnWslSidecar(
),
)
})
const aborted = Promise.withResolvers<never>()
const abort = () => aborted.reject(opts.signal.reason)
opts.signal.addEventListener("abort", abort, { once: true })
const url = `http://127.0.0.1:${port}`
const startup = new AbortController()
const health = pollWslHealth(() => checkHealth(url, password), startup.signal)
Expand All @@ -82,29 +88,33 @@ export async function spawnWslSidecar(
)),
)

await Promise.race([health, exit, timedOut])
.catch((error) => {
child.kill()
await Promise.race([health, exit, timedOut, aborted.promise])
.catch(async (error) => {
await stop(child)
throw error
})
.finally(() => {
clearTimeout(timeout)
startup.abort()
opts.signal.removeEventListener("abort", abort)
})
return {
stop: async () => {
if (child.exitCode !== null || child.signalCode !== null) return
await new Promise<void>((resolve) => {
child.once("exit", () => resolve())
child.kill()
})
},
stop: () => stop(child),
onExit: (cb) => child.once("exit", cb),
url,
password,
}
}

function stop(child: ChildProcess) {
// A process that never started or already exited has nothing to wait for.
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve) => {
child.once("exit", () => resolve())
child.kill()
})
}

async function checkHealth(url: string, password: string) {
const auth = Buffer.from(`opencode:${password}`).toString("base64")
return fetch(new URL("/api/info", url), {
Expand Down