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
Binary file added ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc
Binary file not shown.
Binary file added ops/hub/__pycache__/jev_placement.cpython-313.pyc
Binary file not shown.
6 changes: 3 additions & 3 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@
"packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e",
"packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535",
"packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da",
"packages/app/src/context/server-sdk.test.ts": "cc49ef76cf77b260a95f7209368aba811e27f08bffc5d232cb2d9a2c797cde15",
"packages/app/src/context/server-sdk.tsx": "881f661c0981892a3d4ba3e812fa9044bb9ceb20b21da28ba69c64c477dfb065",
"packages/app/src/context/server-sdk.test.ts": "90a1dbe415fcb0a6c6864a03c3403e59884f18e7af6eae9a5d3ebd80f1341408",
"packages/app/src/context/server-sdk.tsx": "a5f508cb504c2431e1d01387135ce7292eb6cac2c95b9b0bbb7a83b66e67ddba",
"packages/app/src/context/server-session.test.ts": "1ae7a90493e1e7c5eb314da1927baa1b18d1cfa5557409f7bcc73e1d03b66e9e",
"packages/app/src/context/server-session.ts": "4d85011c9ff81cf0470633f6f27cd9f11c234294c31e2c77a990aad135d317ab",
"packages/app/src/context/server-sync.tsx": "14396d7402d697ab9a10744c9e428b96e483f3a9620a0415246fa840596fd8ed",
Expand Down Expand Up @@ -917,7 +917,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-10-13T11:44:30.785Z",
"extracted_at": "2026-10-13T11:16:58.304Z",
"per_package": {
"packages/app": {
"A": 193,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
enqueueServerEvent,
HEARTBEAT_CADENCE_MS,
isStreamClosed,
postEventStreamUrlAssert,
resumeStreamAfterPageShow,
} from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
Expand Down Expand Up @@ -306,6 +307,38 @@ describe("dead-man wiring in the reader loop (#1751)", () => {
const body = source.slice(onDead, onDead + 200)
expect(body).toContain('setStreamStatus("disconnected")')
expect(body).toContain("attempt?.abort()")
// #1760 P4 (4b) — the telemetry-visible assert: the global SDK event stream's
// URL must be PROVABLE in production (the headless probe greps the hub's
// client-errors.log for this line and asserts it equals the hub URL when the
// hub-host client posture is armed — "stuck thinking while the engine runs
// fine" was a panel pointed at a private bus, and nothing in production could
// prove it).
describe("postEventStreamUrlAssert — the global event stream URL rides the #1748 channel", () => {
test("posts a greppable JSON line naming the stream's URL", () => {
const bodies: string[] = []
postEventStreamUrlAssert("http://127.0.0.1:4097", (b) => bodies.push(b))
expect(bodies.length).toBe(1)
expect(JSON.parse(bodies[0])).toEqual({ assert: "global-sdk-event-url", url: "http://127.0.0.1:4097" })
})

test("the DEFAULT channel is the serving-origin client log (root-relative /__amicode_client_log POST, fire-and-forget)", () => {
const originalFetch = globalThis.fetch
const recorded: Array<{ url: string; method: string; body: string }> = []
// eslint-disable-next-line require-await
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
recorded.push({ url: String(input), method: String(init?.method ?? "GET"), body: String(init?.body ?? "") })
return new Response(null, { status: 204 })
}) as typeof fetch
try {
postEventStreamUrlAssert("http://127.0.0.1:4097")
} finally {
globalThis.fetch = originalFetch
}
expect(recorded.length).toBe(1)
expect(recorded[0].url).toBe("/__amicode_client_log")
expect(recorded[0].method).toBe("POST")
expect(recorded[0].body).toContain("global-sdk-event-url")
expect(recorded[0].body).toContain("http://127.0.0.1:4097")
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { postClientLog } from "@/amicode/client-telemetry"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
Expand Down Expand Up @@ -250,6 +251,18 @@ export function createDeadMansSwitch(onDead: () => void, thresholdMs = deadManTh
},
}
}
/** #1760 P4 (4b) — the telemetry-visible assert: the global SDK event
* stream's URL, posted to the serving origin's #1748 client log
* (`/__amicode_client_log`) so the hub-host client posture is provable in
* production, not just in tests — the headless probe greps the hub's
* client-errors.log for this line and asserts it equals the hub URL (the
* live failure it kills: a panel whose global stream pointed at a private
* bus, invisible until this line existed). The channel is fire-and-forget
* (postClientLog's contract), so a non-amicode serving origin 404s it
* silently — harmless everywhere, decisive on the hub. */
export function postEventStreamUrlAssert(url: string, post: (body: string) => void = postClientLog) {
post(`{"assert":"global-sdk-event-url","url":${JSON.stringify(url)}}`)
}

type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
Expand Down Expand Up @@ -404,6 +417,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
const start = () => {
if (started) return run
started = true
// #1760 P4 (4b): ship the assert BEFORE the first attempt — the stream's
// URL is the fact the probe verifies, and it must reach the log even if
// the stream itself never connects (a dead door is exactly when the line
// matters most).
postEventStreamUrlAssert(server.http.url)
const active = ++generation
const previous = run
const current = (async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@
"default": 0,
"description": "Fleet client poll tuning (#1202): after a standalone offer, no new offer within this many minutes — the cooldown survives a recovery, so a flapping tunnel cannot extract repeated popups. 0 = the default (15 minutes)."
},
"amicode.hubServerUrl": {
"type": "string",
"default": "",
"description": "Hub-host client posture (#1760 P4): when set to a valid http(s) URL, this machine's extension spawns NOTHING (no local opencode engine, no amicode service) and the entire panel — server resolution, global event stream, session views, question dock, replies — points at this hub URL (one server, no split). On the hub host itself use http://127.0.0.1:4097. Works on all platforms including Linux remote-SSH hosts. Empty = today's behavior (spawn locally)."
},
"amicode.opencodePort": {
"type": "number",
"default": 43117,
Expand Down
156 changes: 150 additions & 6 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ import { adoptOrSpawn, buildLiveDeps, isPidAlive, reclaimOrphanPort, auditAdopte
import { handshakePath, readHandshake, deleteHandshake, serverLogPath, coldSpawnHandshakeHook, hashFile, hashString } from "./server_handshake";
import { startKeepalive, stopKeepalive, readGraceSeconds, pingKeepalive } from "./server_keepalive";
import { FleetPollHysteresis } from "./fleet_poll_hysteresis";
import {
readHubClientConfig,
bootPosture,
startHubClientSession,
guardReservedHubPort,
type HubClientSession,
} from "./hub_client";
import { stopServer } from "./stop_server";
import type { QueueView } from "./qick_job_server";
import { postDeviceStatus, postDeviceActions, postDeviceActivate } from "./inspector_bridge";
Expand All @@ -129,6 +136,10 @@ let distillerSetup: DistillerSetup | undefined;
let devicePollTimer: ReturnType<typeof setInterval> | undefined;
/** Fleet client tunnel poll — when the machine is a fleet client (guard `exit 1`), we don't spawn. */
let fleetClientPoll: ReturnType<typeof setInterval> | undefined;
/** #1760 P4: the hub client session — set when amicode.hubServerUrl armed the
* hub-host client posture (spawn NOTHING; every server resolution = the hub
* URL). Undefined = the base posture. */
let hubSession: HubClientSession | undefined;

const DEVICE_POLL_MS = 2500; // mirror the RunsManager cadence

Expand Down Expand Up @@ -745,12 +756,98 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
});
ctx.subscriptions.push({ dispose: () => unregisterBugReport() });

// #1760 P4 — the hub-host client posture: amicode.hubServerUrl armed →
// spawn NOTHING and ride the hub (ALL platforms; the darwin-only fleet
// guard does NOT gate this — erlich is a Linux remote-SSH host and is
// exactly the machine that needs the posture). The explicit setting
// outranks the inferred guard (bootPosture). An invalid value fails to the
// base posture with the named reason, never silently.
const hubClient = readHubClientConfig(
vscode.workspace.getConfiguration("amicode").get<string>("hubServerUrl", ""),
);
if (!hubClient.armed && hubClient.reason !== "no amicode.hubServerUrl configured") {
opencodeChannel.appendLine(`[hub] ${hubClient.reason} — base posture (spawn locally)`);
}

// Fleet client: guard would `exit 1` on this host (role=client in the
// projection topology) — don't spawn and storm "opencode failed to start
// within 30s". Ride the tunnel instead; "Go Standalone" switches to local
// mode permanently. (#338; the projection read is #1106.)
const fleetClient = isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line));
if (binary !== undefined && fleetClient) {
const posture = bootPosture({
hubArmed: hubClient.armed,
fleetClient,
hasBinary: binary !== undefined,
});
if (posture === "hub-client") {
opencodeChannel.appendLine(`[hub] ${hubClient.reason}: ${hubClient.url}`);
// Distiller still arms on the hub client (uses the vendored binary
// directly, headless — never a server spawn; same as the fleet client).
const clientBinary = (() => {
try {
return resolveOpencodeBinary(ctx.extensionPath, "").path;
} catch {
return undefined;
}
})();
if (opencodeProject.vaultDir && clientBinary) {
try {
distillerSetup = {
binary: clientBinary,
distillerMdPath: path.resolve(ctx.extensionPath, "DISTILLER.md"),
vaultDir: opencodeProject.vaultDir,
opsDir: amicodeOpsDir(),
problemsRoot: path.join(os.homedir(), ".amico", "problems"),
runsRoot,
model: vscode.workspace.getConfiguration("amicode").get<string>("distillerModel", "opencode/big-pickle"),
};
initDistillerTransport(distillerSetup);
opencodeChannel.appendLine(`[boot] distiller armed (hub client, vault: ${opencodeProject.vaultDir})`);
} catch (e) {
opencodeChannel.appendLine(`[boot] distiller transport failed: ${e}`);
distillerSetup = undefined;
}
}
sseClient = new OpencodeEventClient({
channel: opencodeChannel,
statusBar,
authorization: serverAuthHeaders.Authorization,
});
ctx.subscriptions.push(sseClient);
// The hub session: probe the hub, attach through hysteresis. ONE server,
// no split — attach points EVERY resolution at the hub URL: the panel
// frames the hub (frameUrl() has no amicodeService, so it resolves the
// ready URL = the hub; the framed app derives its whole server registry
// from the document origin, so the registry IS { hub }), the SSE event
// stream connects to the hub, and the question dock + replies live behind
// the framed app's single server. No onboarding gate here (the hub owns
// the provider config; the local gate would read stale local state).
hubSession = startHubClientSession({
hubUrl: hubClient.url,
log: (line) => opencodeChannel.appendLine(line),
authHeader: serverAuthHeaders.Authorization,
onAttach: (url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
if (vscode.workspace.getConfiguration("amicode").get<boolean>("chat.autoOpen", true)) {
ChatPanel.openOrReveal(ctx, frameUrl() ?? url, serverAuthToken(serverPassword), opencodeProject.projectDir);
}
void fetchProviderSignal(url.toString(), { headers: serverAuthHeaders }).then((sig) => {
opencodeChannel.appendLine(
sig.ok
? `[hub] LLM provider: configured (${sig.provider})`
: `[hub] LLM provider: ${sig.reason} → ${sig.fix}`,
);
});
},
onDetach: () => {
opencodeReadyUrl = undefined;
statusBar?.setServerReady(false);
},
});
ctx.subscriptions.push({ dispose: () => { hubSession?.stop(); hubSession = undefined; } });
} else if (posture === "fleet-client") {
const topology = readFleetTopology();
const fleetPort = topology.kind === "ok" ? (topology.canonical?.port ?? 4096) : 4096;
opencodeChannel.appendLine(`[fleet] client mode — guard ${binary} would refuse on ${os.hostname()} — riding tunnel 127.0.0.1:${fleetPort}`);
Expand Down Expand Up @@ -871,7 +968,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
void checkFleet();
// Fallback status bar already handles the fallback-active case; in pure
// client mode we surface tunnel health via the fleet health warning above.
} else if (binary !== undefined) {
// (posture "local" implies hasBinary by bootPosture's contract; the
// explicit re-check also keeps TS's `binary` narrowing for the branch body)
} else if (posture === "local" && binary !== undefined) {
// The amico CLI is argv-only (β.1) — no AMICO_* env propagation (S37), with ONE
// recorded exception: AMICO_PYTHON (Pasqal python provisioning) rides the
// server child env for the FORK's validator spawn — server plumbing, not
Expand All @@ -888,10 +987,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// auth.json. The spawn env carries only PATH (so the amico CLI resolves), the
// amico instructions/permission config, and the per-boot server password
// that arms the fork's route auth (#163).
const configuredPort = vscode.workspace.getConfiguration("amicode").get<number>("opencodePort", 0);
// #1760 P4: the reserved hub port guard — a pin in 4094-4097 (the hub
// plane's ports) is refused with a named line and falls back to ephemeral.
const configuredPort = guardReservedHubPort(
vscode.workspace.getConfiguration("amicode").get<number>("opencodePort", 0),
"amicode.opencodePort",
(line) => opencodeChannel.appendLine(line),
);
if (configuredPort > 0) {
opencodeChannel.appendLine(`[boot] amicode.opencodePort = ${configuredPort} (static)`);
}
// The service rides enginePort+1 for the stable-origin rule — guarded too
// (a 4093 pin would otherwise put the service ON the reserved 4094).
const configuredServicePort =
configuredPort > 0 ? guardReservedHubPort(configuredPort + 1, "amicode.opencodePort + 1 (the service port)", (l) => opencodeChannel.appendLine(l)) || undefined : undefined;

// #1145 (ADR 0020): adopt-or-spawn decision — read the handshake and run
// the four-check gate BEFORE creating a ServerManager. On adopt, reuse the
Expand Down Expand Up @@ -1091,7 +1200,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// stays stable across window reloads — preserving localStorage (settings,
// titlebar positions, developer tool paths). Falls back to ephemeral if
// the derived port is busy.
port: configuredPort > 0 ? configuredPort + 1 : undefined,
port: configuredServicePort,
});
amicodeService = serviceBoot ?? undefined;
ctx.subscriptions.push(amicodeServiceDisposal(serviceBoot));
Expand Down Expand Up @@ -1442,7 +1551,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
),
fleetActivation: () =>
resolveFleetActivation({ config: readFleetActivationConfig(vscode.workspace.getConfiguration("amicode")) }),
port: configuredPort > 0 ? configuredPort + 1 : undefined,
port: configuredServicePort,
});
amicodeService = adoptedServiceBoot ?? undefined;
ctx.subscriptions.push(amicodeServiceDisposal(adoptedServiceBoot));
Expand Down Expand Up @@ -1520,7 +1629,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
if (c === "Reload window") void vscode.commands.executeCommand("workbench.action.reloadWindow");
return;
}
const port = vscode.workspace.getConfiguration("amicode").get<number>("opencodePort", 0);
// #1760 P4: same reserved-hub-port guard on the respawn's own port read.
const port = guardReservedHubPort(
vscode.workspace.getConfiguration("amicode").get<number>("opencodePort", 0),
"amicode.opencodePort",
(line) => opencodeChannel.appendLine(line),
);
const project2 = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
templateSrc: path.resolve(
Expand Down Expand Up @@ -1909,6 +2023,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
refreshFleetStatus();

const runFleetGoStandalone = async (): Promise<void> => {
// #1760 P4: hub client → standalone means CLEARING the posture setting
// first (the setting is the explicit record — the local spawn below must
// never run while it still names a hub), stopping the hub poll, then the
// existing standalone flow (fresh local server, ephemeral port) proceeds.
const hubCfgNow = vscode.workspace.getConfiguration("amicode");
if (readHubClientConfig(hubCfgNow.get<string>("hubServerUrl", "")).armed) {
try {
await hubCfgNow.update("hubServerUrl", "", vscode.ConfigurationTarget.Global);
} catch (e) {
opencodeChannel.appendLine(`[hub] Go Standalone: clearing amicode.hubServerUrl failed: ${(e as Error).message}`);
}
hubSession?.stop();
hubSession = undefined;
opencodeChannel.appendLine(`[hub] Go Standalone — amicode.hubServerUrl cleared, spawning a local server`);
}
const topology = readFleetTopology();
if (topology.kind === "ok" && topology.role === "standalone") {
void vscode.window.showInformationMessage("Amicode: already in standalone mode (local server).");
Expand Down Expand Up @@ -2527,6 +2656,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// the bridge allowlist already permits it, so the server restart here
// clears the "Something went wrong" state instead of being a no-op.
opencodeChannel.appendLine(`[boot] restart requested`);
// #1760 P4: hub client — NOTHING local to restart (no spawn ever ran);
// poke the hub session's probe (hysteresis-correct re-attach). Live
// setting read: a mid-session disarm falls through to the local path
// honestly instead of poking a session that no longer exists.
if (
readHubClientConfig(vscode.workspace.getConfiguration("amicode").get<string>("hubServerUrl", "")).armed
) {
opencodeChannel.appendLine(`[hub] restart requested — hub client mode: re-probing the hub`);
hubSession?.poke();
return;
}
// Fleet client: no local server to restart — just re-probe the tunnel
if (binary !== undefined && isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line))) {
const topologyRestart = readFleetTopology();
Expand Down Expand Up @@ -2661,6 +2801,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
}

export function deactivate(): void {
if (hubSession) {
hubSession.stop();
hubSession = undefined;
}
if (fleetClientPoll) {
clearInterval(fleetClientPoll);
fleetClientPoll = undefined;
Expand Down
Loading
Loading