@remodex/rmx 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,10 +20,7 @@ const USAGE = `Usage:
20
20
  rmx system update check [--channel <latest|preview>] [--json]
21
21
  rmx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
22
22
  rmx system update status <job-id> [--json]
23
- rmx system update auto <on|off|status> [--channel <latest|preview>] [--json]
24
- rmx system desktop-update check [--channel <latest|preview>] [--json]
25
- rmx system desktop-update run [--channel <latest|preview>] [--json]
26
- rmx system desktop-update status [--json]`;
23
+ rmx system update auto <on|off|status> [--channel <latest|preview>] [--json]`;
27
24
 
28
25
  async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
29
26
  const args = [...argv];
@@ -132,86 +129,6 @@ async function update(argv: string[], deps: RuntimeApiDeps): Promise<void> {
132
129
  printData(result, wantsJson, [`Update started (${channel}).`]);
133
130
  }
134
131
 
135
- function desktopUpdateSummary(value: unknown): string[] {
136
- if (!value || typeof value !== "object") return [`Desktop update: ${String(value)}`];
137
- const record = value as Record<string, unknown>;
138
- const state = record.state;
139
- const check = record;
140
- const lines: string[] = [];
141
-
142
- if (typeof check.currentVersion === "string") {
143
- lines.push(`Current version: ${check.currentVersion}`);
144
- }
145
- if (typeof check.latestVersion === "string") {
146
- lines.push(`Latest version: ${check.latestVersion}`);
147
- }
148
- if (typeof check.phase === "string") {
149
- lines.push(`Status: ${check.phase}`);
150
- }
151
- if (state && typeof state === "object" && !Array.isArray(state)) {
152
- const stateRecord = state as Record<string, unknown>;
153
- if (typeof stateRecord.phase === "string") lines.push(`Status: ${stateRecord.phase}`);
154
- if (typeof stateRecord.latestVersion === "string") {
155
- lines.push(`Latest version: ${stateRecord.latestVersion}`);
156
- }
157
- if (typeof stateRecord.progress === "number") {
158
- lines.push(`Progress: ${Math.round(stateRecord.progress)}%`);
159
- }
160
- if (typeof stateRecord.errorCode === "string") lines.push(`Error: ${stateRecord.errorCode}`);
161
- }
162
- if (check.updateAvailable === true) lines.push("Update available.");
163
- else if (check.updateAvailable === false) lines.push("No desktop update available.");
164
- if (lines.length > 0) return lines;
165
- return summaryLines(value);
166
- }
167
-
168
- async function desktopUpdate(argv: string[], deps: RuntimeApiDeps): Promise<void> {
169
- const args = [...argv];
170
- const action = (args.shift() ?? "check").toLowerCase();
171
- const wantsJson = takeFlag(args, "--json");
172
-
173
- if (action === "status") {
174
- rejectArgs(args, USAGE);
175
- const result = await runtimeRequest("/api/desktop-update/status", {}, deps);
176
- printData(result, wantsJson, desktopUpdateSummary(result));
177
- return;
178
- }
179
-
180
- if (action !== "check" && action !== "run") {
181
- throw new CliUsageError(`unknown desktop-update action ${action}`, USAGE);
182
- }
183
-
184
- const channel = takeOption(args, "--channel") ?? "latest";
185
- if (channel !== "latest" && channel !== "preview") {
186
- throw new CliUsageError("--channel must be latest or preview", USAGE);
187
- }
188
- rejectArgs(args, USAGE);
189
-
190
- if (action === "check") {
191
- const result = await runtimeRequest(
192
- `/api/desktop-update/check?channel=${encodeURIComponent(channel)}`,
193
- {},
194
- deps,
195
- );
196
- printData(result, wantsJson, desktopUpdateSummary(result));
197
- return;
198
- }
199
-
200
- // Headless callers can fetch and verify a native installer, but installation
201
- // stays restricted to the exact-origin desktop/Tauri bridge. This mirrors
202
- // the management API contract and prevents a shell or remote dashboard from
203
- // silently launching a platform installer.
204
- const result = await runtimeRequest(
205
- "/api/desktop-update/run",
206
- {
207
- method: "POST",
208
- body: JSON.stringify({ channel, install: false }),
209
- },
210
- deps,
211
- );
212
- printData(result, wantsJson, [`Desktop update download started (${channel}).`]);
213
- }
214
-
215
132
  export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
216
133
  return runCliAction(async () => {
217
134
  const [sub = "status", ...rest] = argv;
@@ -225,7 +142,6 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps =
225
142
  const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
226
143
  printData(await runtimeRequest("/api/sync", { method: "POST" }, deps), wantsJson);
227
144
  } else if (sub === "update") await update(rest, deps);
228
- else if (sub === "desktop-update") await desktopUpdate(rest, deps);
229
145
  else throw new CliUsageError(`unknown system command ${sub}`, USAGE);
230
146
  });
231
147
  }
@@ -778,6 +778,8 @@ export function chooseCatalogPathForInjection(
778
778
 
779
779
  export interface CodexInjectResult {
780
780
  success: boolean;
781
+ /** Whether plain Codex routing in the main config.toml is owned by Remodex. */
782
+ routingApplied?: boolean;
781
783
  message: string;
782
784
  status?: "skipped";
783
785
  skippedReason?: "desired_disabled" | "desired_enabled";
@@ -842,6 +844,7 @@ export async function injectCodexConfig(
842
844
  if (!shouldSyncCodexOnStart(loadConfig())) {
843
845
  return {
844
846
  success: true,
847
+ routingApplied: false,
845
848
  status: "skipped",
846
849
  skippedReason: "desired_disabled",
847
850
  message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
@@ -868,10 +871,12 @@ export async function injectCodexConfig(
868
871
  : undefined;
869
872
  return {
870
873
  success: true,
874
+ routingApplied: false,
871
875
  ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
872
876
  message:
873
877
  `${adoptedProviderMessage}` +
874
878
  `Codex config preserved byte-for-byte with external model_provider ${tomlString(activeProvider)}.\n` +
879
+ ` This is why the main ${CODEX_CONFIG_PATH} did not change.\n` +
875
880
  ` Remodex Codex profile: ${CODEX_PROFILE_PATH} (codex --profile opencodex)\n` +
876
881
  (profileCatalogPath
877
882
  ? ` Codex model catalog: ${profileCatalogPath}\n`
@@ -1267,6 +1272,7 @@ export async function injectCodexConfig(
1267
1272
  if (keptUserBaseUrl) {
1268
1273
  return {
1269
1274
  success: true,
1275
+ routingApplied: false,
1270
1276
  ...(nativeSubagentDefaultsWarning
1271
1277
  ? { nativeSubagentDefaultsWarning }
1272
1278
  : {}),
@@ -1275,7 +1281,7 @@ export async function injectCodexConfig(
1275
1281
  catalogMessage +
1276
1282
  historyMessage +
1277
1283
  managedDefaultsMessage +
1278
- ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'rmx start'.\n` +
1284
+ ` To route plain codex through the proxy, remove your openai_base_url line from ${CODEX_CONFIG_PATH} and rerun 'rmx sync'.\n` +
1279
1285
  ` Reference config: ${CODEX_PROFILE_PATH}`,
1280
1286
  };
1281
1287
  }
@@ -1284,6 +1290,7 @@ export async function injectCodexConfig(
1284
1290
  : `Pointed Codex's built-in openai provider at the Remodex proxy (openai_base_url).\n`;
1285
1291
  return {
1286
1292
  success: true,
1293
+ routingApplied: true,
1287
1294
  ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
1288
1295
  message:
1289
1296
  headline +
package/src/codex/sync.ts CHANGED
@@ -25,6 +25,8 @@ export interface CodexSyncResult {
25
25
  catalogExists: boolean;
26
26
  catalogWritten: boolean;
27
27
  cacheSynced: boolean;
28
+ /** Whether plain Codex routing in config.toml is owned by Remodex. */
29
+ routingApplied?: boolean;
28
30
  message: string;
29
31
  warning?: string;
30
32
  comboOmissions?: ComboCatalogOmission[];
@@ -226,6 +228,7 @@ export async function syncModelsToCodex(
226
228
  catalogExists,
227
229
  catalogWritten,
228
230
  cacheSynced,
231
+ ...(result.routingApplied !== undefined ? { routingApplied: result.routingApplied } : {}),
229
232
  message: result.message,
230
233
  ...(warning ? { warning } : {}),
231
234
  ...(comboOmissions.length > 0 ? { comboOmissions } : {}),
@@ -134,7 +134,7 @@ async function statusDTO(
134
134
  ? await controller.cloudflareConfiguration()
135
135
  : {
136
136
  mode: state.settings.tunnelMode,
137
- ...(state.settings.namedTunnelHostname
137
+ ...(state.settings.tunnelMode === "named" && state.settings.namedTunnelHostname
138
138
  ? { namedHostname: state.settings.namedTunnelHostname }
139
139
  : {}),
140
140
  hasNamedTunnelToken: false,
@@ -377,86 +377,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
377
377
  return jsonResponse({ ok: true, job });
378
378
  }
379
379
 
380
- /**
381
- * Native desktop releases are deliberately separate from the npm runtime
382
- * updater above. The manifest/check/download state is shared with the
383
- * Tauri tray through `desktop-update.json`; a normal browser can inspect it
384
- * and check GitHub. Installer launch never crosses this management route;
385
- * only the exact-origin Tauri bridge can request it.
386
- */
387
- if (url.pathname === "/api/desktop-update/check" && req.method === "GET") {
388
- const checkDesktopReleaseUpdate = deps.checkDesktopReleaseUpdate
389
- ?? (await import("../../update/desktop-release")).checkDesktopReleaseUpdate;
390
- const rawChannel = url.searchParams.get("channel");
391
- if (rawChannel && rawChannel !== "latest" && rawChannel !== "preview") {
392
- return jsonResponse({ error: "channel must be latest or preview" }, 400, req, config);
393
- }
394
- return jsonResponse(
395
- await checkDesktopReleaseUpdate(
396
- rawChannel === "preview" ? "preview" : "latest",
397
- // A management request is never proof that a verified native shell
398
- // initiated it. Browser callers may check and download only; the
399
- // Tauri bridge owns installation.
400
- { desktopShell: () => false },
401
- ),
402
- 200,
403
- req,
404
- config,
405
- );
406
- }
407
-
408
- if (url.pathname === "/api/desktop-update/run" && req.method === "POST") {
409
- const desktopRelease = await import("../../update/desktop-release");
410
- const startDesktopUpdateJob = deps.startDesktopUpdateJob
411
- ?? desktopRelease.startDesktopUpdateJob;
412
- let body: unknown;
413
- try {
414
- body = await readManagementJsonBody(req);
415
- } catch (error) {
416
- rethrowManagementBodyTooLarge(error);
417
- return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
418
- }
419
- if (!body || typeof body !== "object" || Array.isArray(body)) {
420
- return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
421
- }
422
- const update = body as { channel?: unknown; install?: unknown };
423
- if (update.channel !== undefined && update.channel !== "latest" && update.channel !== "preview") {
424
- return jsonResponse({ error: "channel must be latest or preview" }, 400, req, config);
425
- }
426
- if (update.install !== undefined && typeof update.install !== "boolean") {
427
- return jsonResponse({ error: "install boolean is required" }, 400, req, config);
428
- }
429
- if (update.install === true) {
430
- return jsonResponse(
431
- {
432
- error: "Open the Remodex desktop application to install this update.",
433
- code: "desktop_required",
434
- },
435
- 409,
436
- req,
437
- config,
438
- );
439
- }
440
- try {
441
- const job = startDesktopUpdateJob(
442
- update.channel === "preview" ? "preview" : "latest",
443
- { install: false },
444
- );
445
- return jsonResponse({ ok: true, state: job }, 202, req, config);
446
- } catch (error) {
447
- if (error instanceof desktopRelease.DesktopUpdateError) {
448
- return jsonResponse({ error: error.message, code: error.code }, error.status, req, config);
449
- }
450
- return jsonResponse({ error: "desktop update could not start", code: "download_failed" }, 500, req, config);
451
- }
452
- }
453
-
454
- if (url.pathname === "/api/desktop-update/status" && req.method === "GET") {
455
- const readDesktopUpdateState = deps.readDesktopUpdateState
456
- ?? (await import("../../update/desktop-release")).readDesktopUpdateState;
457
- return jsonResponse({ ok: true, state: readDesktopUpdateState() }, 200, req, config);
458
- }
459
-
460
380
  if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
461
381
  const ws = config.webSearchSidecar ?? {};
462
382
  const vs = config.visionSidecar ?? {};
@@ -12,10 +12,6 @@ import type { AndroidRemoteGatewayController } from "../../android-remote/gatewa
12
12
  import type { AndroidRemoteCloudflareProvisioner } from "../../android-remote/cloudflare-provisioning";
13
13
 
14
14
  export interface ManagementApiDeps {
15
- /** Native desktop-update seams keep route tests off the network and real owner state. */
16
- checkDesktopReleaseUpdate?: typeof import("../../update/desktop-release").checkDesktopReleaseUpdate;
17
- startDesktopUpdateJob?: typeof import("../../update/desktop-release").startDesktopUpdateJob;
18
- readDesktopUpdateState?: typeof import("../../update/desktop-release").readDesktopUpdateState;
19
15
  /** Android Remote persistence seam. Tests inject an in-memory store. */
20
16
  androidRemoteStore?: AndroidRemoteStore;
21
17
  /** Shared Android Remote gateway. Production and management routes use one controller. */
@@ -109,7 +109,7 @@ function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) {
109
109
  return $true
110
110
  } catch {
111
111
  Write-ActionLog "launch failed: $($_.Exception.GetType().Name)"
112
- $notify.ShowBalloonTip(5000, "Remodex action failed", "The action could not start. Open the logs folder or run rmx doctor.", [System.Windows.Forms.ToolTipIcon]::Error)
112
+ $notify.ShowBalloonTip(5000, "Remodex action failed", "The action could not start. Run rmx doctor for details.", [System.Windows.Forms.ToolTipIcon]::Error)
113
113
  return $false
114
114
  }
115
115
  }
@@ -152,20 +152,27 @@ function Read-JsonUrl([string]$Url) {
152
152
 
153
153
  $notify = New-Object System.Windows.Forms.NotifyIcon
154
154
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
155
- $statusItem = New-Object System.Windows.Forms.ToolStripMenuItem
156
- $statusItem.Enabled = $false
157
- $safetyItem = New-Object System.Windows.Forms.ToolStripMenuItem
158
- $safetyItem.Enabled = $false
159
- $openItem = $menu.Items.Add("Open Dashboard")
160
- $startItem = $menu.Items.Add("Start Proxy")
161
- $stopItem = $menu.Items.Add("Stop Proxy and Restore Native Routing")
162
- $restartItem = $menu.Items.Add("Restart Proxy")
155
+ $statusItem = $menu.Items.Add("🔴 Offline · Refresh")
156
+ $openItem = $menu.Items.Add("Open dashboard")
163
157
  [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
164
- [void]$menu.Items.Add($statusItem)
165
- [void]$menu.Items.Add($safetyItem)
166
- $logsItem = $menu.Items.Add("Open Logs Folder")
158
+ $proxyLifecycleItem = $menu.Items.Add("Start Proxy")
159
+ $applyChangesItem = $menu.Items.Add("Apply Changes")
160
+ $restartCodexItem = $menu.Items.Add("Restart Codex")
161
+ $restartRemodexItem = $menu.Items.Add("Restart Remodex")
167
162
  [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
168
- $exitItem = $menu.Items.Add("Exit Tray")
163
+ $restartDesktopItem = $menu.Items.Add("Restart desktop application (advanced)…")
164
+ $checkUpdateItem = $menu.Items.Add("Check package updates")
165
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
166
+ $exitItem = $menu.Items.Add("Quit desktop shell")
167
+
168
+ $actionItems = @(
169
+ $proxyLifecycleItem,
170
+ $applyChangesItem,
171
+ $restartCodexItem,
172
+ $restartRemodexItem,
173
+ $restartDesktopItem,
174
+ $checkUpdateItem
175
+ )
169
176
 
170
177
  $script:online = $false
171
178
  $script:port = 10100
@@ -175,8 +182,29 @@ $script:pendingStarted = 0L
175
182
  $script:pendingDeadline = 0L
176
183
  $script:pendingOldProxyPid = $null
177
184
  $script:pendingProcess = $null
185
+ $script:pendingExpectation = $null
186
+ $script:pendingItem = $null
187
+
188
+ function Reset-ActionLabels {
189
+ $proxyLifecycleItem.Text = if ($script:online) { "Stop Proxy" } else { "Start Proxy" }
190
+ $applyChangesItem.Text = "Apply Changes"
191
+ $restartCodexItem.Text = "Restart Codex"
192
+ $restartRemodexItem.Text = "Restart Remodex"
193
+ $restartDesktopItem.Text = "Restart desktop application (advanced)…"
194
+ $checkUpdateItem.Text = "Check package updates"
195
+ }
196
+
197
+ function Update-ActionAvailability {
198
+ $busy = $null -ne $script:pendingAction
199
+ foreach ($item in $actionItems) { $item.Enabled = -not $busy }
200
+ }
178
201
 
179
- function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) {
202
+ function Set-PendingAction(
203
+ [string]$Action,
204
+ [int]$TimeoutSeconds,
205
+ [ValidateSet("online", "offline", "restarted", "process")][string]$Expectation,
206
+ [System.Windows.Forms.ToolStripMenuItem]$Item
207
+ ) {
180
208
  if ($null -ne $script:pendingAction) {
181
209
  Write-ActionLog "$Action ignored because $($script:pendingAction) is still pending"
182
210
  return $false
@@ -193,6 +221,9 @@ function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) {
193
221
  $script:pendingStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
194
222
  $script:pendingDeadline = $script:pendingStarted + ($TimeoutSeconds * 1000)
195
223
  $script:pendingOldProxyPid = $script:proxyPid
224
+ $script:pendingExpectation = $Expectation
225
+ $script:pendingItem = $Item
226
+ Update-ActionAvailability
196
227
  return $true
197
228
  }
198
229
 
@@ -200,6 +231,8 @@ function Complete-PendingAction([bool]$Success) {
200
231
  if ($null -eq $script:pendingAction) { return }
201
232
  $action = $script:pendingAction
202
233
  $script:pendingAction = $null
234
+ $script:pendingExpectation = $null
235
+ $script:pendingItem = $null
203
236
  if ($null -ne $script:pendingProcess) {
204
237
  try {
205
238
  $script:pendingProcess.Dispose()
@@ -213,7 +246,29 @@ function Complete-PendingAction([bool]$Success) {
213
246
  $notify.ShowBalloonTip(2500, "Remodex", "$action completed.", [System.Windows.Forms.ToolTipIcon]::Info)
214
247
  } else {
215
248
  Write-ActionLog "$action failed to reach the expected state"
216
- $notify.ShowBalloonTip(5000, "Remodex action failed", "$action did not reach the expected state. Open the logs folder or run rmx doctor.", [System.Windows.Forms.ToolTipIcon]::Error)
249
+ $notify.ShowBalloonTip(5000, "Remodex action failed", "$action did not reach the expected state. Run rmx doctor for details.", [System.Windows.Forms.ToolTipIcon]::Error)
250
+ }
251
+ Reset-ActionLabels
252
+ Update-ActionAvailability
253
+ }
254
+
255
+ function Start-PendingCommand(
256
+ [string]$Action,
257
+ [string]$ProgressLabel,
258
+ [string[]]$CommandArgs,
259
+ [int]$TimeoutSeconds,
260
+ [ValidateSet("online", "offline", "restarted", "process")][string]$Expectation,
261
+ [System.Windows.Forms.ToolStripMenuItem]$Item
262
+ ) {
263
+ if (-not (Set-PendingAction -Action $Action -TimeoutSeconds $TimeoutSeconds -Expectation $Expectation -Item $Item)) {
264
+ return
265
+ }
266
+ $Item.Text = $ProgressLabel
267
+ $pending = Start-OcxCommand $CommandArgs -TrackExit
268
+ if ($pending -is [System.Diagnostics.Process]) {
269
+ $script:pendingProcess = $pending
270
+ } else {
271
+ Complete-PendingAction $false
217
272
  }
218
273
  }
219
274
 
@@ -226,34 +281,27 @@ function Update-TrayState {
226
281
  $pidMatches = $null -eq $target.pid -or [int]$target.pid -eq [int]$health.pid
227
282
  $script:online = $null -ne $health -and $health.status -eq "ok" -and $health.service -eq "opencodex" -and [int]$health.port -eq $script:port -and $pidMatches
228
283
  $script:proxyPid = if ($script:online) { [int]$health.pid } else { $null }
284
+ $degraded = $false
229
285
  if ($script:online) {
230
- $statusItem.Text = "Proxy: Online (port $($script:port))"
231
286
  $notify.Text = "Remodex: Online"
232
- $startItem.Enabled = $false
233
- $stopItem.Enabled = $true
234
- $restartItem.Enabled = $true
235
287
  try {
236
288
  $startup = Read-JsonUrl "$origin/api/startup-health"
237
- $label = if ($startup.status -eq "at-risk") { "At risk" } elseif ($startup.status -eq "protected") { "Protected" } else { "Native routing" }
238
- $safetyItem.Text = "Restart safety: $label"
239
- $notify.Icon = if ($startup.status -eq "at-risk") { $warningIcon } else { $onlineIcon }
289
+ $degraded = $startup.status -eq "at-risk"
290
+ $notify.Icon = if ($degraded) { $warningIcon } else { $onlineIcon }
240
291
  } catch {
241
- $safetyItem.Text = "Restart safety: unavailable"
292
+ $degraded = $true
242
293
  $notify.Icon = $warningIcon
243
294
  }
295
+ $dot = if ($degraded) { "🟠" } else { "🟢" }
296
+ $label = if ($degraded) { "Degraded" } else { "Ready" }
297
+ $statusItem.Text = "$dot $label · port $($script:port) · PID $($script:proxyPid) · Refresh"
244
298
  } else {
245
- $statusItem.Text = "Proxy: Offline"
246
- $safetyItem.Text = "Restart safety: start the proxy to inspect"
299
+ $statusItem.Text = "🔴 Offline · Refresh"
247
300
  $notify.Text = "Remodex: Offline"
248
301
  $notify.Icon = $offlineIcon
249
- $startItem.Enabled = $true
250
- $stopItem.Enabled = $false
251
- $restartItem.Enabled = $false
252
302
  }
253
303
  if ($null -ne $script:pendingAction) {
254
- $startItem.Enabled = $false
255
- $stopItem.Enabled = $false
256
- $restartItem.Enabled = $false
304
+ $statusItem.Text = "🟡 $($script:pendingAction)…"
257
305
  }
258
306
  $heartbeat = @{ pid = $PID; timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
259
307
  if ($HostPid -gt 0) { $heartbeat.hostPid = $HostPid }
@@ -263,13 +311,15 @@ function Update-TrayState {
263
311
  if ($null -ne $script:pendingAction) {
264
312
  $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
265
313
  $elapsed = $now - $script:pendingStarted
266
- $reached = ($script:pendingAction -eq "Start Proxy" -and $script:online) -or
267
- ($script:pendingAction -eq "Stop Proxy" -and -not $script:online) -or
268
- ($script:pendingAction -eq "Restart Proxy" -and $elapsed -gt 3000 -and $script:online -and $script:proxyPid -ne $script:pendingOldProxyPid)
314
+ $reached = ($script:pendingExpectation -eq "online" -and $script:online) -or
315
+ ($script:pendingExpectation -eq "offline" -and -not $script:online) -or
316
+ ($script:pendingExpectation -eq "restarted" -and $elapsed -gt 3000 -and $script:online -and $script:proxyPid -ne $script:pendingOldProxyPid)
269
317
  $commandFailed = $false
318
+ $commandComplete = $false
270
319
  if ($null -ne $script:pendingProcess) {
271
320
  try {
272
- $commandFailed = $script:pendingProcess.HasExited -and $script:pendingProcess.ExitCode -ne 0
321
+ $commandComplete = $script:pendingProcess.HasExited
322
+ $commandFailed = $commandComplete -and $script:pendingProcess.ExitCode -ne 0
273
323
  } catch {
274
324
  Write-ActionLog "pending process result inspection failed: $($_.Exception.GetType().Name)"
275
325
  # If we cannot inspect the tracked command, we cannot prove it is still
@@ -279,52 +329,45 @@ function Update-TrayState {
279
329
  }
280
330
  }
281
331
  if ($commandFailed) { Complete-PendingAction $false }
332
+ elseif ($script:pendingExpectation -eq "process" -and $commandComplete) { Complete-PendingAction $true }
282
333
  elseif ($reached) { Complete-PendingAction $true }
283
334
  elseif ($now -gt $script:pendingDeadline) { Complete-PendingAction $false }
284
335
  }
336
+ if ($null -eq $script:pendingAction) { Reset-ActionLabels }
337
+ Update-ActionAvailability
285
338
  }
286
339
 
287
340
  $openItem.add_Click({ Start-OcxCommand @("gui") })
288
- $startItem.add_Click({
289
- if (-not (Set-PendingAction "Start Proxy" 75)) { return }
290
- $statusItem.Text = "Proxy: Starting..."
291
- # service start can spend 20s and the CLI then observes health for another 40s.
292
- $startProcess = Start-OcxCommand @("__tray-start") -TrackExit
293
- if ($startProcess -is [System.Diagnostics.Process]) {
294
- $script:pendingProcess = $startProcess
295
- } else {
296
- Complete-PendingAction $false
297
- }
341
+ $statusItem.add_Click({
342
+ $statusItem.Text = " Checking runtime…"
343
+ Update-TrayState
298
344
  })
299
- $stopItem.add_Click({
300
- if (-not (Set-PendingAction "Stop Proxy" 15)) { return }
301
- $statusItem.Text = "Proxy: Stopping..."
302
- $stopProcess = Start-OcxCommand @("stop") -TrackExit
303
- if ($stopProcess -is [System.Diagnostics.Process]) {
304
- $script:pendingProcess = $stopProcess
345
+ $proxyLifecycleItem.add_Click({
346
+ if ($script:online) {
347
+ Start-PendingCommand -Action "Stop Proxy" -ProgressLabel "Stopping Proxy…" -CommandArgs @("stop") -TimeoutSeconds 20 -Expectation "offline" -Item $proxyLifecycleItem
305
348
  } else {
306
- Complete-PendingAction $false
349
+ # service start can spend 20s and the CLI then observes health for another 40s.
350
+ Start-PendingCommand -Action "Start Proxy" -ProgressLabel "Starting Proxy…" -CommandArgs @("__tray-start") -TimeoutSeconds 75 -Expectation "online" -Item $proxyLifecycleItem
307
351
  }
308
352
  })
309
- $restartItem.add_Click({
310
- if (-not (Set-PendingAction "Restart Proxy" 160)) { return }
311
- $statusItem.Text = "Proxy: Restarting..."
353
+ $applyChangesItem.add_Click({
354
+ Start-PendingCommand -Action "Apply Changes" -ProgressLabel "Applying changes…" -CommandArgs @("sync") -TimeoutSeconds 180 -Expectation "process" -Item $applyChangesItem
355
+ })
356
+ $restartCodexItem.add_Click({
357
+ Start-PendingCommand -Action "Restart Codex" -ProgressLabel "Restarting Codex…" -CommandArgs @("__desktop-restart-codex") -TimeoutSeconds 60 -Expectation "process" -Item $restartCodexItem
358
+ })
359
+ $restartRemodexItem.add_Click({
312
360
  # /api/system/restart may drain active work for 60s and then spend up to 70s
313
361
  # handing off to an identity-verified replacement. The tray observes health/PID
314
362
  # rather than the detached CLI exit, so keep a watchdog margin around that shared
315
363
  # lifecycle budget. The CLI remains the lifecycle owner; the tray never kills.
316
- $restartProcess = Start-OcxCommand @("__tray-restart") -TrackExit
317
- if ($restartProcess -is [System.Diagnostics.Process]) {
318
- $script:pendingProcess = $restartProcess
319
- } else {
320
- Complete-PendingAction $false
321
- }
364
+ Start-PendingCommand -Action "Restart Remodex" -ProgressLabel "Restarting Remodex…" -CommandArgs @("__tray-restart") -TimeoutSeconds 160 -Expectation "restarted" -Item $restartRemodexItem
365
+ })
366
+ $restartDesktopItem.add_Click({
367
+ Start-PendingCommand -Action "Restart desktop application" -ProgressLabel "Restarting desktop application…" -CommandArgs @("__desktop-restart-client") -TimeoutSeconds 60 -Expectation "process" -Item $restartDesktopItem
322
368
  })
323
- $logsItem.add_Click({
324
- $psi = New-Object System.Diagnostics.ProcessStartInfo
325
- $psi.FileName = $OpenCodexHome
326
- $psi.UseShellExecute = $true
327
- [void][System.Diagnostics.Process]::Start($psi)
369
+ $checkUpdateItem.add_Click({
370
+ Start-PendingCommand -Action "Open package updater" -ProgressLabel "Opening package updater…" -CommandArgs @("gui", "--update") -TimeoutSeconds 90 -Expectation "process" -Item $checkUpdateItem
328
371
  })
329
372
  $exitItem.add_Click({ [System.Windows.Forms.Application]::Exit() })
330
373
  $notify.add_DoubleClick({ Start-OcxCommand @("gui") })
@@ -647,7 +647,13 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
647
647
  try {
648
648
  const hardenedDir = hardenSecretDir(getConfigDir(), { required: true });
649
649
  if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
650
- replaceWindowsTrayOwnedFile(entry.script, readFileSync(sourceScript));
650
+ // Windows PowerShell 5.1 treats a BOM-less script as the active ANSI code
651
+ // page. The parity menu deliberately uses the same Unicode status dots and
652
+ // ellipsis as the desktop shell, so install the trusted source as UTF-8 BOM.
653
+ replaceWindowsTrayOwnedFile(
654
+ entry.script,
655
+ Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), readFileSync(sourceScript)]),
656
+ );
651
657
  for (const pair of iconPairs) replaceWindowsTrayOwnedFile(pair.installed, readFileSync(pair.source));
652
658
  replaceWindowsTrayOwnedFile(launcherPath, Buffer.from("\uFEFF" + buildWindowsTrayLauncherScript(entry), "utf16le"));
653
659
  runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
@@ -736,6 +736,26 @@ function systemdQuote(value: string): string {
736
736
  .replace(/%/g, "%%")}"`;
737
737
  }
738
738
 
739
+ /**
740
+ * `WorkingDirectory=` accepts a path value, not the quoted argument form used
741
+ * by `ExecStart=` and `Environment=`. Encode whitespace and unit-significant
742
+ * characters as systemd escapes so paths with spaces remain valid without
743
+ * leaving literal quotes in the directory name.
744
+ */
745
+ function systemdPath(value: string): string {
746
+ let escaped = "";
747
+ for (const character of value) {
748
+ if (character === "\\") escaped += "\\\\";
749
+ else if (character === "%") escaped += "%%";
750
+ else if (character === " ") escaped += "\\x20";
751
+ else if (character === "\t") escaped += "\\x09";
752
+ else if (character === "\n") escaped += "\\x0a";
753
+ else if (character === '"') escaped += "\\x22";
754
+ else escaped += character;
755
+ }
756
+ return escaped;
757
+ }
758
+
739
759
  function systemdEnvironment(name: string, value: string): string {
740
760
  return `Environment=${systemdQuote(`${name}=${value}`)}`;
741
761
  }
@@ -760,7 +780,7 @@ Wants=network-online.target
760
780
  [Service]
761
781
  Type=oneshot
762
782
  ExecStart=${systemdQuote(input.nodePath)} ${systemdQuote(input.launcherPath)} __auto-update
763
- WorkingDirectory=${systemdQuote(input.configDir)}
783
+ WorkingDirectory=${systemdPath(input.configDir)}
764
784
  ${env.join("\n")}
765
785
  TimeoutStartSec=15min
766
786