Turn Launcher host Disconnect into Reconnect

Keep the Core host popover open after a hard disconnect and reuse the connection action to restart the owning Launcher tab lease. Retain the master toggle as the reversible Pause control and cover the bounded bridge contract.

Alessandro committed Jul 16, 2026 at 04:42 UTC 3c23b9b94ed6ab9796e5ec2fd121daefb32b4242
4 files changed +74 -12
plugins/_a0_connector/AGENTS.md
+5 -2
@@ -42,14 +42,17 @@
42 older gateway declarations without `file_write` read/write compatible.
43 - The `sync-status-end` Launcher gateway extension renders only when the user agent
44 includes `A0-Launcher/`. It may show status, master/scope controls,
45 - preparation errors, and Disconnect; standard browser sessions must
45 + preparation errors, and the connection action; standard browser sessions must
46 not expose it. Keep its trigger inside the existing sync indicator, render one
47 computer glyph per connected Launcher gateway, and retain one muted glyph
48 while disconnected so the control remains reachable. Do not add a native
49 tooltip, visible status label, count badge, or animated expansion.
50 Keep the open popover above the compact right-canvas rail and report control
51 failures through the shared notification system. Use the standard WebUI
52 - border-radius tokens for its controls and popover.
52 + border-radius tokens for its controls and popover. After Disconnect is
53 + acknowledged, keep the popover open and turn that same action into Reconnect;
54 + use only the narrow Launcher WebContents bridge so this also works in a
55 + detached Instance window. Pausing remains the reversible master switch.
56 - File operation results may arrive as chunked JSON/base64
57 `connector_file_op_result` frames; resolve the pending file operation only
58 after all chunks for the `op_id` are assembled.
plugins/_a0_connector/extensions/webui/sync-status-end/launcher-gateway.html
+9 -6
@@ -109,11 +109,13 @@
109
110 <button
111 type="button"
112 - class="launcher-gateway-disconnect"
113 - x-show="$store.launcherGateway.gateway"
112 + class="launcher-gateway-connection-action"
113 + :class="{ 'is-reconnect': !$store.launcherGateway.gateway }"
114 + x-show="$store.launcherGateway.gateway || $store.launcherGateway.reconnectAvailable"
115 :disabled="$store.launcherGateway.saving"
115 - @click="$store.launcherGateway.emergencyDisconnect()"
116 - >Disconnect</button>
116 + @click="$store.launcherGateway.gateway ? $store.launcherGateway.emergencyDisconnect() : $store.launcherGateway.reconnect()"
117 + x-text="$store.launcherGateway.saving ? ($store.launcherGateway.gateway ? 'Disconnecting…' : 'Reconnecting…') : ($store.launcherGateway.gateway ? 'Disconnect' : 'Reconnect')"
118 + ></button>
119 </section>
120 </div>
121
@@ -157,6 +159,7 @@
159 .launcher-gateway-notices p { display: flex; gap: .35rem; margin: 0; color: #d79b35; font-size: .75rem; line-height: 1.3; }
160 .launcher-gateway-notices .material-symbols-outlined { font-size: .95rem; }
161 .launcher-gateway-empty { margin: .7rem 0 0; color: var(--color-text-secondary); font-size: .78rem; }
160 - .launcher-gateway-disconnect { width: 100%; margin-top: .75rem; padding: .48rem .65rem; border: 1px solid color-mix(in srgb, #db5b61 55%, var(--color-border)); border-radius: var(--border-radius-sm); background: transparent; color: #db5b61; cursor: pointer; }
161 - .launcher-gateway-disconnect:disabled { opacity: .55; cursor: default; }
162 + .launcher-gateway-connection-action { width: 100%; margin-top: .75rem; padding: .48rem .65rem; border: 1px solid color-mix(in srgb, #db5b61 55%, var(--color-border)); border-radius: var(--border-radius-sm); background: transparent; color: #db5b61; cursor: pointer; }
163 + .launcher-gateway-connection-action.is-reconnect { border-color: var(--color-highlight); background: var(--color-highlight); color: #fff; }
164 + .launcher-gateway-connection-action:disabled { opacity: .55; cursor: default; }
165 </style>
plugins/_a0_connector/webui/launcher-gateway-store.js
+43 -3
@@ -11,13 +11,18 @@ const model = {
11 saving: false,
12 open: false,
13 intervalId: null,
14 + reconnectAvailable: false,
15
16 get visible() {
17 return /(?:^|\s)A0-Launcher\/[^\s]+/.test(navigator.userAgent);
18 },
19
20 + get canReconnect() {
21 + return typeof window.a0LauncherHost?.reconnect === "function";
22 + },
23 +
24 get gateway() {
20 - return this.status?.gateway || null;
25 + return this.state === "disconnected" ? null : this.status?.gateway || null;
26 },
27
28 get state() {
@@ -75,6 +80,16 @@ const model = {
80 this.status = { state: "error", gateway: null, error: error?.message || "Status unavailable" };
81 } finally {
82 this.loading = false;
83 + await this.refreshReconnectState();
84 + }
85 + },
86 +
87 + async refreshReconnectState() {
88 + try {
89 + const state = await window.a0LauncherHost?.getState?.();
90 + this.reconnectAvailable = state?.reconnectAvailable === true;
91 + } catch {
92 + this.reconnectAvailable = false;
93 }
94 },
95
@@ -98,8 +113,31 @@ const model = {
113 },
114
115 async emergencyDisconnect() {
101 - await this.control({ action: "emergency_disconnect" });
102 - this.open = false;
116 + if (await this.control({ action: "emergency_disconnect" })) {
117 + this.reconnectAvailable = this.canReconnect;
118 + this.status = { ...this.status, state: "disconnected", connected: false, gateway: null, gateways: [] };
119 + if (!this.canReconnect) this.open = false;
120 + }
121 + },
122 +
123 + async reconnect() {
124 + if (this.saving) return;
125 + this.saving = true;
126 + try {
127 + const response = await window.a0LauncherHost?.reconnect?.();
128 + if (!response?.ok) throw new Error(response?.message || "Host access could not reconnect.");
129 + this.reconnectAvailable = false;
130 + this.status = { ...this.status, state: "connecting", connected: false, gateway: null, gateways: [] };
131 + } catch (error) {
132 + console.error("Failed to reconnect Launcher host:", error);
133 + void toastFrontendError(
134 + error?.message || "Host access could not reconnect.",
135 + "Host access",
136 + );
137 + await this.refreshReconnectState();
138 + } finally {
139 + this.saving = false;
140 + }
141 },
142
143 async control(payload) {
@@ -108,6 +146,7 @@ const model = {
146 try {
147 const response = await callJsonApi(CONTROL_API, payload);
148 this.status = response?.status || this.status;
149 + return true;
150 } catch (error) {
151 console.error("Failed to control Launcher host:", error);
152 void toastFrontendError(
@@ -115,6 +154,7 @@ const model = {
154 "Host access",
155 );
156 await this.refresh();
157 + return false;
158 } finally {
159 this.saving = false;
160 }
tests/test_a0_connector_launcher_gateway.py
+17 -1
@@ -78,12 +78,28 @@ def test_launcher_gateway_indicator_joins_sync_status_without_visual_noise() ->
78 assert "launcher-gateway-trigger-label" not in source
79 assert "launcher-gateway-dot" not in source
80 assert ":title=" not in source
81 - assert ">Disconnect</button>" in source
81 + assert "'Disconnect' : 'Reconnect'" in source
82 + assert "$store.launcherGateway.gateway ? $store.launcherGateway.emergencyDisconnect() : $store.launcherGateway.reconnect()" in source
83 assert "Emergency disconnect" not in source
84 popover_css = source.split(".launcher-gateway-popover {", 1)[1].split("}", 1)[0]
85 assert "border-radius: var(--border-radius-sm);" in popover_css
86
87
88 +def test_launcher_gateway_disconnect_becomes_reconnect_through_the_launcher_bridge() -> None:
89 + source = (
90 + Path(__file__).parents[1]
91 + / "plugins"
92 + / "_a0_connector"
93 + / "webui"
94 + / "launcher-gateway-store.js"
95 + ).read_text(encoding="utf-8")
96 + assert "window.a0LauncherHost?.getState?.()" in source
97 + assert "window.a0LauncherHost?.reconnect?.()" in source
98 + assert 'typeof window.a0LauncherHost?.reconnect === "function"' in source
99 + assert 'state: "disconnected", connected: false, gateway: null' in source
100 + assert 'state: "connecting", connected: false, gateway: null' in source
101 +
102 +
103 def test_launcher_gateway_is_fallback_after_context_bound_cli() -> None:
104 context_id = f"ctx-{uuid.uuid4()}"
105 cli_sid = _sid("cli")