diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index 5d9f8f16..f8e4e8fa 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -694,9 +694,10 @@
"packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914",
"packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65",
"packages/app/src/components/prompt-input/context-items.tsx": "027b02e394ccd14849052bf3fcacdae1e090c71d164fa8955addfe3f7abf1758",
- "packages/app/src/components/prompt-input/harness-control.tsx": "3711e0b3ca5d0b041e956e67c41594609faf134edc3ede803f09ff8b574667cc",
+ "packages/app/src/components/prompt-input/harness-control.test.tsx": "859cdacbc71988e8ae2b02840bb98a9b7442b63d6f88c0671648bddccfc4e6fb",
+ "packages/app/src/components/prompt-input/harness-control.tsx": "6f3825cb6b312e6b1d85b7af0da99976cbb15082822a92e869f956f5ec2e91fd",
"packages/app/src/components/prompt-input/harness.test.ts": "35739529b06829dd9fa169209c54ae668e0fc06d6968b00cfce638a221ce1361",
- "packages/app/src/components/prompt-input/harness.ts": "8ae6a783c18a906383061cb1254101015f025080b39be3786c27be9bcd0ba9d1",
+ "packages/app/src/components/prompt-input/harness.ts": "2a9406b14da9de68a172e61b6ddb4e49442a90f770b1b88669761f2ec201f9ab",
"packages/app/src/components/prompt-input/image-attachments.tsx": "aa451ed6aa61a68ba0e8dacd8c45137f84c62566797a5242b75a89487ef0e9bf",
"packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3",
"packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d",
@@ -984,10 +985,10 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
- "extracted_at": "2026-10-13T15:58:01.063Z",
+ "extracted_at": "2026-10-14T21:01:06.170Z",
"per_package": {
"packages/app": {
- "A": 244,
+ "A": 245,
"M": 159,
"D": 0
},
@@ -1033,7 +1034,7 @@
}
},
"counts": {
- "overlay_total": 962,
+ "overlay_total": 963,
"deletions": 7,
"server_coupled": 0
},
@@ -1724,6 +1725,7 @@
"packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A",
"packages/app/src/components/prompt-input/clipboard-bridge.ts": "A",
"packages/app/src/components/prompt-input/context-items.tsx": "M",
+ "packages/app/src/components/prompt-input/harness-control.test.tsx": "A",
"packages/app/src/components/prompt-input/harness-control.tsx": "A",
"packages/app/src/components/prompt-input/harness.test.ts": "A",
"packages/app/src/components/prompt-input/harness.ts": "A",
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx
new file mode 100644
index 00000000..5946f16b
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx
@@ -0,0 +1,188 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import type { ServerConnection } from "@/context/server"
+import { adoptHarnessState, harnessRoute, liveHarnessState, refreshHarnessState } from "./harness"
+
+// amicode#1799 — the harness composer control never rendered in the hub-client
+// posture. The composer's mount-path fetch was the ONLY instance-scoped ask in
+// the app that carried no ?directory= placement key: /amicode/harness is an
+// INSTANCE route (routes/instance/httpapi), and every other instance-scoped
+// fetch the composer makes (/agent, /provider, /command, /question…) carries
+// the location directory — the key the hub frontdoor routes instance requests
+// by. In the LOCAL posture the panel origin is the extension service, whose
+// #1549 exception forwards the bare ask to its bound engine (one engine, one
+// instance), so the bare route worked. In the hub-client posture (#1784) the
+// panel origin IS the hub frontdoor: a directory-less instance ask cannot be
+// placed and lands on the default backend, which answers 200 text/html with
+// the SPA index document (verified live: bare /amicode/harness → text/html,
+// /amicode/harness?directory=… → application/json registry). The HTML answer
+// fails amicodeGet's .json() parse, the catch adopts undefined, and
+// liveHarnessState() — the control's render gate — stays never-true: zero
+// renders while /agent and /provider fetch fine.
+//
+// The component's behavioral substance (route construction + fetch + adopt)
+// lives in the state module (./harness) so the mount path is testable without
+// a DOM (the solver-toggle decision-helper split; bun's transpiler runs no
+// Solid JSX transform — see lineage-ledger-panel.test.tsx). This suite drives
+// that seam under BOTH postures' server contexts and pins the composer wiring.
+
+const READY_MENU = {
+ ok: true,
+ harness: "opencode",
+ status: "ready",
+ options: [
+ { id: "opencode", displayName: "opencode (default)", state: "ready", detail: "The default harness.", disabled: false },
+ { id: "telaio", displayName: "telaio", state: "needs-setup", detail: "Requires the telaio binary.", disabled: true, reason: "telaio binary not installed" },
+ ],
+}
+
+// The P4 hub-client posture's server context: the framed app derives its
+// ENTIRE registry from the document origin — registry IS {hub}, a single http
+// connection, no local sidecar (#1784). The context object the control reads
+// is structurally its `.current` accessor.
+const HUB_ORIGIN = "http://127.0.0.1:4097"
+const HUB_CONN = { type: "http", http: { url: HUB_ORIGIN } } as ServerConnection.Any
+const DIRECTORY = "/home/aaron/harmoniqs/opencode"
+
+// What the hub answered the bare ask with, verbatim shape (200 text/html).
+const SPA_INDEX = "
OpenCode"
+
+const registryJson = () =>
+ new Response(JSON.stringify(READY_MENU), { status: 200, headers: { "content-type": "application/json" } })
+const spaIndexHtml = () => new Response(SPA_INDEX, { status: 200, headers: { "content-type": "text/html" } })
+
+/** The hub frontdoor's placement semantics for the harness route, as observed
+ * live (#1799): a directory-carrying ask is placed to the directory's member
+ * engine and answered with the registry JSON; a directory-less ask lands on
+ * the default backend and is answered with the SPA index document. */
+const hubFrontdoor = (recorded: string[]) =>
+ (async (input: RequestInfo | URL) => {
+ const url = new URL(String(input))
+ recorded.push(url.pathname + url.search)
+ if (url.pathname !== "/amicode/harness") throw new Error(`unexpected probe route: ${url.pathname}`)
+ return url.searchParams.has("directory") ? registryJson() : spaIndexHtml()
+ }) as typeof fetch
+
+describe("#1799 harnessRoute — the placement-correct ask", () => {
+ test("carries the instance placement directory, the key every other instance-scoped composer fetch rides", () => {
+ expect(harnessRoute(DIRECTORY)).toBe(`/amicode/harness?directory=${encodeURIComponent(DIRECTORY)}`)
+ })
+
+ test("a missing directory keeps the bare ask — the local posture's original behavior", () => {
+ expect(harnessRoute("")).toBe("/amicode/harness")
+ })
+})
+
+describe("#1799 the mount-path refresh in the hub-client posture (the framed app's registry IS {hub})", () => {
+ const originalFetch = globalThis.fetch
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ adoptHarnessState(undefined)
+ })
+
+ test("renders the control: the ask is placed by directory and the registry adopts (RED on the bare ask)", async () => {
+ const recorded: string[] = []
+ globalThis.fetch = hubFrontdoor(recorded)
+ await refreshHarnessState({ current: HUB_CONN }, DIRECTORY)
+ // The live RED: the old bare ask got the SPA index → no state → the
+ // control's gate stayed never-true.
+ expect(liveHarnessState()?.harness).toBe("opencode")
+ expect(recorded).toHaveLength(1)
+ expect(recorded[0]).toContain("directory=")
+ })
+
+ test("the old bare ask collapses: the hub's 200 text/html SPA answer adopts no state — the never-render, reproduced", async () => {
+ const recorded: string[] = []
+ globalThis.fetch = hubFrontdoor(recorded)
+ await refreshHarnessState({ current: HUB_CONN }, "")
+ expect(liveHarnessState()).toBeUndefined()
+ expect(recorded).toEqual(["/amicode/harness"])
+ })
+
+ test("no connection → stays hidden, no fetch (the server.current guard's degradation, unchanged)", async () => {
+ const recorded: string[] = []
+ globalThis.fetch = hubFrontdoor(recorded)
+ await refreshHarnessState({ current: undefined }, DIRECTORY)
+ expect(liveHarnessState()).toBeUndefined()
+ expect(recorded).toHaveLength(0)
+ })
+})
+
+describe("#1799 the mount-path refresh in the local posture — unchanged", () => {
+ const originalFetch = globalThis.fetch
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ adoptHarnessState(undefined)
+ })
+
+ test("the service origin forwards the ask to the bound engine (query preserved) — the control mounts", async () => {
+ const recorded: string[] = []
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = new URL(String(input))
+ recorded.push(url.pathname + url.search)
+ if (url.pathname !== "/amicode/harness") throw new Error(`unexpected probe route: ${url.pathname}`)
+ return registryJson()
+ }) as typeof fetch
+ await refreshHarnessState({ current: { type: "http", http: { url: "http://127.0.0.1:4095" } } as ServerConnection.Any }, DIRECTORY)
+ expect(liveHarnessState()?.harness).toBe("opencode")
+ expect(recorded).toHaveLength(1)
+ })
+
+ test("route absent → stays hidden (the stock-engine degradation contract)", async () => {
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ ok: false, error: "no route" }), { status: 404 })) as typeof fetch
+ await refreshHarnessState({ current: HUB_CONN }, DIRECTORY)
+ expect(liveHarnessState()).toBeUndefined()
+ })
+})
+
+describe("#1799 the refresh sequence — only the LATEST ask adopts", () => {
+ const originalFetch = globalThis.fetch
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ adoptHarnessState(undefined)
+ })
+
+ test("a stale in-flight answer never clobbers a newer ask's state (the two mount effects can race)", async () => {
+ // Live shape: the mount effects (server-change + stream-connect) can both
+ // have asks in flight; during a member switch one answer came back the
+ // SPA index while the other carried the registry — last-writer-wins on
+ // the module singleton made renders racy. Only the latest ask may adopt.
+ const gates: Array<(response: Response) => void> = []
+ globalThis.fetch = (async () => new Promise((resolve) => gates.push(resolve))) as typeof fetch
+ const first = refreshHarnessState({ current: HUB_CONN }, DIRECTORY)
+ const second = refreshHarnessState({ current: HUB_CONN }, DIRECTORY)
+ gates[1]?.(registryJson())
+ await second
+ gates[0]?.(spaIndexHtml())
+ await first
+ expect(liveHarnessState()?.harness).toBe("opencode")
+ })
+})
+
+describe("#1799 the composer control's wiring (the mount effects + the switch write)", () => {
+ const source = readFileSync(resolve(__dirname, "harness-control.tsx"), "utf8")
+
+ test("the mount effects keep the server.current and stream-connected guards AND thread the placement directory", () => {
+ expect(source).toContain("if (server.current) void refreshHarnessState(server, directory())")
+ expect(source).toContain('if (sdk().event.status() === "connected") void refreshHarnessState(server, directory())')
+ })
+
+ test("the placement directory comes from the per-directory SDK context — the same source every other instance-scoped composer fetch rides", () => {
+ expect(source).toContain("const dirSdk = useSDK()")
+ expect(source).toContain("dirSdk().directory")
+ })
+
+ test("the switch POST carries the placement route too (the write path is the same instance route)", () => {
+ expect(source).toContain("amicodePost(conn, harnessRoute(directory())")
+ })
+
+ test("the fetch/parse/adopt body lives in the testable state module, not inlined in the component", () => {
+ expect(source).not.toContain("amicodeGet(server.current")
+ expect(source).not.toContain("adoptHarnessState(parseHarnessState(")
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx
index 5d5e2f61..2399bb56 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx
@@ -5,9 +5,10 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
-import { amicodeGet, amicodePost } from "@/utils/amicode-fetch"
+import { useSDK } from "@/context/sdk"
+import { amicodePost } from "@/utils/amicode-fetch"
import { showToast } from "@/utils/toast"
-import { adoptHarnessState, liveHarnessState, parseHarnessState } from "@/components/prompt-input/harness"
+import { liveHarnessState, refreshHarnessState, harnessRoute } from "@/components/prompt-input/harness"
import { beginHarnessSwitch } from "@/components/harness-switch-banner"
// amicode#1549 — the harness switcher in the chat box. A fourth composer
@@ -18,31 +19,36 @@ import { beginHarnessSwitch } from "@/components/harness-switch-banner"
// An unresolvable or unentitled harness renders disabled WITH its reason —
// never selectable-then-failing. No published registry (stock opencode, or
// the extension not yet booted) renders nothing at all.
+//
+// amicode#1799 — /amicode/harness is an INSTANCE route, so both the mount-path
+// GET and the switch POST carry the placement directory (harnessRoute) — the
+// key every other instance-scoped fetch the composer makes rides. The bare
+// ask only ever worked in the local posture (the panel origin's extension
+// proxy forwards to its bound engine); at the hub origin it landed on the
+// default backend and was answered with the SPA index, so the control never
+// rendered. The fetch/parse/adopt body lives in harness.ts (testable without
+// a DOM); this component is the wiring.
/** The palette's disclosure, carried verbatim to the point of switching. */
const DISCLOSURE = "Sessions are harness-local — switching harnesses switches session history."
-async function refreshHarnessState(server: ReturnType): Promise {
- try {
- adoptHarnessState(parseHarnessState(await amicodeGet(server.current, "/amicode/harness")))
- } catch {
- // No answer (no extension yet, route absent) → the control stays hidden.
- adoptHarnessState(undefined)
- }
-}
-
export function HarnessComposerControl() {
const server = useServer()
const sdk = useServerSDK()
+ // The instance placement directory — the per-directory SDK context the
+ // composer already lives under (the same source the agent/model fetches
+ // ride), so the harness ask routes exactly like every other composer fetch.
+ const dirSdk = useSDK()
+ const directory = () => dirSdk().directory
// Refetch on server change and on every (re)connect — a harness switch
// restarts the server, so the post-restart reconnect is when the new
// current lands here.
createEffect(() => {
- if (server.current) void refreshHarnessState(server)
+ if (server.current) void refreshHarnessState(server, directory())
})
createEffect(() => {
- if (sdk().event.status() === "connected") void refreshHarnessState(server)
+ if (sdk().event.status() === "connected") void refreshHarnessState(server, directory())
})
const onPick = async (id: string) => {
@@ -51,7 +57,7 @@ export function HarnessComposerControl() {
const conn = server.current
if (!conn) return
try {
- const res = (await amicodePost(conn, "/amicode/harness", { harness: id })) as {
+ const res = (await amicodePost(conn, harnessRoute(directory()), { harness: id })) as {
ok?: unknown
noop?: unknown
harness?: unknown
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts
index 2c41e825..43a82b45 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts
@@ -6,6 +6,8 @@
// behavior, unchanged. Pure helpers so the view contract is testable without
// a DOM (the solver-toggle decision-helper split).
import { createSignal } from "solid-js"
+import type { ServerConnection } from "@/context/server"
+import { amicodeGet } from "@/utils/amicode-fetch"
export interface HarnessOptionView {
id: string
@@ -78,3 +80,38 @@ export function adoptHarnessState(state: HarnessViewState | undefined): void {
export function liveHarnessState(): HarnessViewState | undefined {
return harnessState()
}
+
+/** The placement-correct ask for the engine's harness route (#1799).
+ * /amicode/harness is an INSTANCE route — the composer's only instance-scoped
+ * amicode ask — so it carries ?directory= like every other instance-scoped
+ * fetch the app makes (/agent, /provider, /command…): the key the hub
+ * frontdoor routes instance requests by. The bare route is placeable only in
+ * the local posture (the panel origin's extension proxy forwards to its
+ * bound engine); at the hub origin a directory-less instance ask lands on
+ * the default backend, which answers 200 text/html with the SPA index — the
+ * control never rendered (#1799). A missing directory keeps the bare ask
+ * (the local posture's original behavior). */
+export function harnessRoute(directory: string): string {
+ return directory ? `/amicode/harness?directory=${encodeURIComponent(directory)}` : "/amicode/harness"
+}
+
+/** Refresh the composer's harness state from the engine — the body both mount
+ * effects call. Sequenced: the state is a module singleton and the two mount
+ * effects (server-change + stream-connect) can have asks in flight at once;
+ * only the LATEST ask's answer may adopt, or a stale response clobbers a
+ * fresh one (observed live: during a hub member switch one answer arrived
+ * the SPA index while the other carried the registry). */
+let harnessRefreshSeq = 0
+export async function refreshHarnessState(
+ server: { current: ServerConnection.Any | undefined },
+ directory: string,
+): Promise {
+ const seq = ++harnessRefreshSeq
+ try {
+ const state = parseHarnessState(await amicodeGet(server.current, harnessRoute(directory)))
+ if (seq === harnessRefreshSeq) adoptHarnessState(state)
+ } catch {
+ // No answer (no extension yet, route absent) → the control stays hidden.
+ if (seq === harnessRefreshSeq) adoptHarnessState(undefined)
+ }
+}