@gotcos/glasses-server 6.18.5 → 6.18.7

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 6.18.7
2
+
3
+ - **Phone Restart no longer leaves the server Stopped.** Control LaunchAgent
4
+ KeepAlive is `SuccessfulExit: false`, so the old SIGTERM→exit(0) path never
5
+ came back — phone showed "Restart requested; reconnect is taking longer than
6
+ expected" while Control sat at Stopped. Restart now `launchctl kickstart -k`
7
+ (fallback `exit(1)` so KeepAlive still fires).
8
+
9
+ ## 6.18.6
10
+
11
+ - **Phone Restart works on COS Control managed installs.** Recovery status /
12
+ `/api/recovery/server/restart` previously required `COS_HARNESS=daemon`, but
13
+ Control's LaunchAgent sets `COS_MANAGED=1` with `COS_HARNESS=foreground`.
14
+ Health correctly advertised `managed: true` while the restart route returned
15
+ 409 "Server is not managed by the COS LaunchAgent". Gate now uses
16
+ `COS_MANAGED=1` (`isManagedRuntime()`), matching capabilities.
17
+
1
18
  ## 6.18.5
2
19
 
3
20
  - **Codex workspace-write now includes outbound network.** `workspace-write`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.5",
3
+ "version": "6.18.7",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { userInfo } from 'node:os'
3
+
4
+ /** LaunchAgent label Control installs for the managed glasses server. */
5
+ export const DEFAULT_LAUNCHD_LABEL = 'com.cos.glasses-server'
6
+
7
+ /**
8
+ * COS Control's LaunchAgent uses KeepAlive SuccessfulExit:false — a clean
9
+ * SIGTERM/exit(0) leaves the service STOPPED. Phone Restart must either
10
+ * kickstart -k (kill + start) or exit non-zero so KeepAlive brings it back.
11
+ */
12
+ export function scheduleManagedServerRestart(options?: {
13
+ delayMs?: number
14
+ label?: string
15
+ kickstart?: (args: string[]) => void
16
+ exitProcess?: (code: number) => void
17
+ onError?: (error: unknown) => void
18
+ }): void {
19
+ const delayMs = options?.delayMs ?? 350
20
+ const label = (options?.label ?? process.env.COS_LAUNCHD_LABEL?.trim()) || DEFAULT_LAUNCHD_LABEL
21
+ const kickstart = options?.kickstart ?? ((args: string[]) => {
22
+ const child = spawn('launchctl', args, { detached: true, stdio: 'ignore' })
23
+ child.unref()
24
+ })
25
+ const exitProcess = options?.exitProcess ?? ((code: number) => { process.exit(code) })
26
+ const onError = options?.onError ?? ((error: unknown) => {
27
+ console.error('[recovery] Failed to schedule managed server restart:', error)
28
+ })
29
+
30
+ const timer = setTimeout(() => {
31
+ if (process.env.COS_DISABLE_SELF_RESTART === '1') return
32
+ try {
33
+ const uid = userInfo().uid
34
+ kickstart(['kickstart', '-k', `gui/${uid}/${label}`])
35
+ // If kickstart did not kill us quickly (misconfigured label), force a
36
+ // non-zero exit so KeepAlive SuccessfulExit:false still restarts.
37
+ setTimeout(() => {
38
+ if (process.env.COS_DISABLE_SELF_RESTART === '1') return
39
+ try { exitProcess(1) } catch (error) { onError(error) }
40
+ }, 2_000).unref?.()
41
+ } catch (error) {
42
+ onError(error)
43
+ try { exitProcess(1) } catch (exitError) { onError(exitError) }
44
+ }
45
+ }, delayMs)
46
+ timer.unref?.()
47
+ }
@@ -21,9 +21,9 @@ export function managedRuntimeCapability(): ManagedRuntimeCapability {
21
21
  // Whisper lifecycle is private to the local controller. It is never
22
22
  // exposed as a network-reachable mutation capability.
23
23
  restartWhisper: false,
24
- // Server restart is performed by the trusted local helper through launchd,
25
- // never by an HTTP endpoint. This flag tells clients that managed recovery
26
- // exists without widening the network attack surface.
24
+ // Phone/companion may POST /api/recovery/server/restart when managed.
25
+ // launchd KeepAlive brings the process back after SIGTERM. Unmanaged
26
+ // foreground installs reject that route (restart_unmanaged).
27
27
  restartServer: managed,
28
28
  maintenanceDrain: managed,
29
29
  lifecycleProof: managed,
@@ -2,6 +2,8 @@ import { Router } from 'express'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { atomicWriteFileSync } from '../lib/atomic-fs.js'
5
+ import { scheduleManagedServerRestart } from '../lib/managed-restart.js'
6
+ import { isManagedRuntime } from '../lib/managed-runtime.js'
5
7
  import { acquireMaintenance, getRecoveryActivityStatus } from '../lib/recovery-activity.js'
6
8
  import { getWhisperHealth, restartWhisperServer } from '../lib/whisper-local.js'
7
9
  import { serverMetrics } from '../lib/server-metrics.js'
@@ -19,14 +21,16 @@ recoveryRouter.get('/live', (_req, res) => {
19
21
  res.json({
20
22
  status: 'ok', bootId: serverMetrics.bootId, pid: process.pid,
21
23
  uptimeSeconds: Math.round((Date.now() - serverMetrics.startedAt) / 1000),
22
- managed: process.env.COS_HARNESS === 'daemon',
24
+ // COS Control LaunchAgent sets COS_MANAGED=1 with COS_HARNESS=foreground.
25
+ // Do not require COS_HARNESS=daemon — that false-negative blocked phone Restart.
26
+ managed: isManagedRuntime(),
23
27
  })
24
28
  })
25
29
 
26
30
  recoveryRouter.get('/recovery/status', (_req, res) => {
27
31
  res.json({
28
32
  bootId: serverMetrics.bootId,
29
- managed: process.env.COS_HARNESS === 'daemon',
33
+ managed: isManagedRuntime(),
30
34
  whisper: getWhisperHealth(),
31
35
  asr: { hqActive: false, hqQueued: 0, fastRestarting: false }, // public build: no HQ/fast ASR scheduler in this server
32
36
  activity: getRecoveryActivityStatus(),
@@ -46,7 +50,7 @@ recoveryRouter.post('/recovery/whisper/restart', async (_req, res) => {
46
50
  })
47
51
 
48
52
  recoveryRouter.post('/recovery/server/restart', (_req, res) => {
49
- if (process.env.COS_HARNESS !== 'daemon') {
53
+ if (!isManagedRuntime()) {
50
54
  return res.status(409).json({ error: 'Server is not managed by the COS LaunchAgent', reason: 'restart_unmanaged' })
51
55
  }
52
56
  const elapsed = Date.now() - lastRestartAt()
@@ -62,15 +66,15 @@ recoveryRouter.post('/recovery/server/restart', (_req, res) => {
62
66
  res.status(202).json({ accepted: true, oldBootId: serverMetrics.bootId })
63
67
  // Schedule independently of the response socket. The phone may change
64
68
  // network/close the sheet immediately after receiving 202; that must not
65
- // cancel an accepted restart or strand maintenance forever.
66
- const timer = setTimeout(() => {
67
- if (process.env.COS_DISABLE_SELF_RESTART === '1') { gate.release(); return }
68
- try {
69
- process.kill(process.pid, 'SIGTERM')
70
- } catch (error) {
69
+ // cancel an accepted restart. Maintenance is in-process only — it dies with us.
70
+ //
71
+ // Do NOT SIGTERM→exit(0): Control's LaunchAgent KeepAlive is
72
+ // SuccessfulExit:false, so a clean exit leaves the service STOPPED and the
73
+ // phone shows "Restart requested; reconnect is taking longer than expected."
74
+ scheduleManagedServerRestart({
75
+ onError: (error) => {
71
76
  gate.release()
72
- console.error('[recovery] Failed to signal managed server restart:', error)
73
- }
74
- }, 350)
75
- timer.unref?.()
77
+ console.error('[recovery] Failed to schedule managed server restart:', error)
78
+ },
79
+ })
76
80
  })