@remodex/rmx 1.0.3 → 1.0.4

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.
@@ -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"]);
package/src/update/job.ts CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  type NpmCachePreflightReason,
44
44
  } from "./npm-cache-preflight.mjs";
45
45
 
46
- const RELEASE_NOTES_URL = "https://github.com/ESCANOR-001/remodex-android/releases/latest";
46
+ const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
47
47
  const UPDATE_JOB_FILENAME = "update-job.json";
48
48
  const UPDATE_TIMEOUT_MS = 180_000;
49
49
  const RESTART_TIMEOUT_MS = 60_000;
@@ -65,6 +65,8 @@ export interface UpdateCheckResult {
65
65
  canUpdate: boolean;
66
66
  command: string;
67
67
  releaseNotesUrl: string;
68
+ /** Local time recorded after this exact runtime version was installed. */
69
+ lastInstalledAt?: string;
68
70
  reason?: string;
69
71
  }
70
72
 
@@ -81,6 +83,10 @@ export interface UpdateJobState {
81
83
  command: string;
82
84
  releaseNotesUrl: string;
83
85
  log: string[];
86
+ /** Exact package version whose installer completed successfully. */
87
+ installedVersion?: string;
88
+ /** Local installation event, persisted as an ISO-8601 timestamp. */
89
+ installedAt?: string;
84
90
  pid?: number;
85
91
  error?: string;
86
92
  exitCode?: number | null;
@@ -98,6 +104,7 @@ export interface UpdateCheckDeps {
98
104
  currentVersion: () => string;
99
105
  detectInstall: () => Installer;
100
106
  latestVersion: (tag: Channel) => string | null;
107
+ readUpdateJob?: () => UpdateJobState | null;
101
108
  }
102
109
 
103
110
  interface UpdateWorkerProcess {
@@ -280,6 +287,27 @@ function isVersionLike(value: unknown): value is string {
280
287
  && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
281
288
  }
282
289
 
290
+ /** Only timestamps written by this updater may cross the management API boundary. */
291
+ function normalizedUpdateTimestamp(value: unknown): string | null {
292
+ if (typeof value !== "string" || value.length > 40) return null;
293
+ const parsed = Date.parse(value);
294
+ if (!Number.isFinite(parsed)) return null;
295
+ const canonical = new Date(parsed).toISOString();
296
+ return canonical === value ? canonical : null;
297
+ }
298
+
299
+ function releaseNotesUrlForVersion(version: unknown): string {
300
+ return isVersionLike(version)
301
+ ? `${RELEASES_URL}/tag/v${version}`
302
+ : RELEASES_URL;
303
+ }
304
+
305
+ function isTrustedReleaseNotesUrl(value: unknown): value is string {
306
+ if (value === RELEASES_URL) return true;
307
+ if (typeof value !== "string" || !value.startsWith(`${RELEASES_URL}/tag/v`)) return false;
308
+ return releaseNotesUrlForVersion(value.slice(`${RELEASES_URL}/tag/v`.length)) === value;
309
+ }
310
+
283
311
  function withheldSummary(error: unknown): string {
284
312
  // `error.name` is writable, so it is external text like the message. A fixed classification
285
313
  // is the only part of an unknown error we can state without repeating something we were
@@ -319,7 +347,7 @@ function withheldSummary(error: unknown): string {
319
347
  */
320
348
  function brandOwnComposedText(key: string, value: unknown): unknown {
321
349
  if (key === "releaseNotesUrl") {
322
- return value === RELEASE_NOTES_URL ? value : "";
350
+ return isTrustedReleaseNotesUrl(value) ? value : "";
323
351
  }
324
352
  if (key === "command") {
325
353
  // Render the command shape first, then apply the same path test as every other field. The
@@ -412,6 +440,11 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
412
440
  const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
413
441
  if (jobId && parsed.id !== jobId) return null;
414
442
  if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
443
+ if (parsed.installedAt !== undefined) {
444
+ const installedAt = normalizedUpdateTimestamp(parsed.installedAt);
445
+ if (installedAt) parsed.installedAt = installedAt;
446
+ else delete parsed.installedAt;
447
+ }
415
448
  return parsed;
416
449
  } catch {
417
450
  return null;
@@ -511,6 +544,12 @@ export function checkForUpdate(
511
544
  reason = "already_latest";
512
545
  }
513
546
 
547
+ const previousJob = (deps.readUpdateJob ?? readUpdateJob)();
548
+ const installedVersion = previousJob?.installedVersion ?? previousJob?.latestVersion;
549
+ const lastInstalledAt = installedVersion === current
550
+ ? normalizedUpdateTimestamp(previousJob?.installedAt)
551
+ : null;
552
+
514
553
  return {
515
554
  currentVersion: current,
516
555
  latestVersion: latest,
@@ -519,7 +558,8 @@ export function checkForUpdate(
519
558
  updateAvailable,
520
559
  canUpdate: installer !== "source" && updateAvailable,
521
560
  command,
522
- releaseNotesUrl: RELEASE_NOTES_URL,
561
+ releaseNotesUrl: releaseNotesUrlForVersion(latest ?? current),
562
+ ...(lastInstalledAt ? { lastInstalledAt } : {}),
523
563
  ...(reason ? { reason } : {}),
524
564
  };
525
565
  }
@@ -1745,6 +1785,8 @@ export interface GuiUpdateWorkerIo {
1745
1785
  checkForUpdateFn?: (channel: Channel) => ReturnType<typeof checkForUpdate>;
1746
1786
  /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
1747
1787
  integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
1788
+ /** Clock seam for deterministic install-event records in tests. */
1789
+ now?: () => string;
1748
1790
  /**
1749
1791
  * Immutable target selected by a caller that already resolved a registry version.
1750
1792
  * npm uses the hidden Node-launcher path so the package manager cannot re-resolve a
@@ -1767,7 +1809,7 @@ export async function runGuiUpdateWorker(
1767
1809
  ): Promise<void> {
1768
1810
  let job = readUpdateJob(jobId);
1769
1811
  const check = (io.checkForUpdateFn ?? checkForUpdate)(channel);
1770
- const now = new Date().toISOString();
1812
+ const now = io.now ?? (() => new Date().toISOString());
1771
1813
  // Capture the live listen target BEFORE the update command runs: the stop-first update
1772
1814
  // flow clears pid/runtime state, so this is the last moment the real port is knowable.
1773
1815
  // Only trust runtime-port.json when its pid matches the live pidfile process.
@@ -1789,8 +1831,8 @@ export async function runGuiUpdateWorker(
1789
1831
  job = {
1790
1832
  id: jobId,
1791
1833
  status: "running",
1792
- startedAt: now,
1793
- updatedAt: now,
1834
+ startedAt: now(),
1835
+ updatedAt: now(),
1794
1836
  currentVersion: check.currentVersion,
1795
1837
  latestVersion: check.latestVersion,
1796
1838
  channel: check.channel,
@@ -1885,6 +1927,12 @@ export async function runGuiUpdateWorker(
1885
1927
  return;
1886
1928
  }
1887
1929
 
1930
+ const installedVersion = io.exactVersion ?? check.latestVersion;
1931
+ job = updateJob(job, {
1932
+ ...(installedVersion ? { installedVersion } : {}),
1933
+ installedAt: now(),
1934
+ }, `Package ${installedVersion ?? "update"} installed successfully.`);
1935
+
1888
1936
  if (trayWasInstalled) {
1889
1937
  const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
1890
1938
  const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  const VERSION_FILENAME = "version.json";
18
18
  const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
19
- const RELEASE_NOTES_URL = "https://github.com/ESCANOR-001/remodex-android/releases/latest";
19
+ const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
20
20
 
21
21
  export interface VersionCache {
22
22
  latest_version: string;
@@ -210,7 +210,7 @@ function renderPrompt(current: string, latest: string, channel: Channel): string
210
210
  "",
211
211
  ` \x1b[38;5;141m✨ Update available!\x1b[0m \x1b[2m${current} -> ${latest}\x1b[0m`,
212
212
  "",
213
- ` \x1b[2mRelease notes:\x1b[0m ${RELEASE_NOTES_URL}`,
213
+ ` \x1b[2mRelease notes:\x1b[0m ${RELEASES_URL}/tag/v${latest}`,
214
214
  "",
215
215
  ` 1) Update now (runs \`${command}\`)`,
216
216
  " 2) Skip",