@gotcos/glasses-server 6.18.3 → 6.18.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.
package/.env.example CHANGED
@@ -53,9 +53,13 @@ BIND_HOST=0.0.0.0
53
53
  # COS_CODEX_MODEL=
54
54
  # COS_CODEX_REASONING_EFFORT=high # low | medium | high | xhigh | max | ultra
55
55
  #
56
- # Codex remains read-only by default. This is the only broader trust opt-in:
56
+ # Codex remains read-only by default. Broader trust opt-in (workdir writes +
57
+ # outbound network inside that sandbox — needed for Gmail API / HTTPS):
57
58
  # COS_CODEX_SANDBOX=workspace-write
58
- #
59
+ # Pair with ~/.codex/config.toml [sandbox_workspace_write] network_access = true
60
+ # for interactive Codex CLI; the managed glasses server also passes
61
+ # -c sandbox_workspace_write.network_access=true when workspace-write is set.
62
+
59
63
  # Claude preserves COS's established trusted-machine behavior by default.
60
64
  # Security-conscious installs can remove the permission bypass and restrict
61
65
  # Claude to COS's explicit per-query tool allowlist. Undeclared tools fail
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## 6.18.5
2
+
3
+ - **Codex workspace-write now includes outbound network.** `workspace-write`
4
+ alone still blocked HTTPS (Gmail API, etc.) because Codex defaults
5
+ `sandbox_workspace_write.network_access` to off. When
6
+ `COS_CODEX_SANDBOX=workspace-write`, the managed server now passes
7
+ `-c sandbox_workspace_write.network_access=true` and the capability header
8
+ states HTTPS is available. Pair with the same key in `~/.codex/config.toml`
9
+ for interactive Codex CLI. Still not `danger-full-access` — writes stay
10
+ inside the workdir. Prefer COS `email_cache` / `email_gmail_api` over
11
+ approval-gated Google Workspace connector sends from glasses.
12
+
13
+ ## 6.18.4
14
+
15
+ - **Meeting sync progress on `/api/health`.** Post-meeting HQ polish writes
16
+ `_batch_progress.json` under `pending-batch/<meetingId>/` and publishes
17
+ `meeting_sync` on health (`active`, `percent`, `label`, `blocksRestart`,
18
+ per-meeting rows). COS Control 0.3.0+ shows this as a status row so Update /
19
+ Restart drain is no longer a black box during long Whisper batch jobs.
20
+
1
21
  ## 6.18.3
2
22
 
3
23
  > Ships as 6.18.3. There is no published 6.18.2 — that version number was bumped
package/README.md CHANGED
@@ -70,7 +70,10 @@ without silently losing completed replies.
70
70
  > omit the execution mode default to Ask.
71
71
  > Existing `COS_CODEX_MODEL` / `COS_CODEX_REASONING_EFFORT` settings remain
72
72
  > supported on the migrated Frontier slot; leave them blank for auto-latest.
73
- > Codex runs **sandboxed read-only** by default (`COS_CODEX_SANDBOX` to adjust).
73
+ > Codex runs **sandboxed read-only** by default. Set
74
+ > `COS_CODEX_SANDBOX=workspace-write` for workdir writes + outbound network
75
+ > (`sandbox_workspace_write.network_access=true` is passed by the managed server
76
+ > and should also be set in `~/.codex/config.toml` for interactive Codex).
74
77
  > **Claude is the most permissive provider by default.** It runs with
75
78
  > `--dangerously-skip-permissions`, so a glasses query on the Claude/Opus path can
76
79
  > run shell commands and read, edit, and write files on this Mac without prompting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.3",
3
+ "version": "6.18.5",
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": {
@@ -83,14 +83,24 @@ const PHASE_LABELS: Record<Phase, string> = {
83
83
  // Sandbox policy for `codex exec`. This public server NEVER runs codex
84
84
  // unsandboxed — that would let a remote glasses query execute arbitrary commands
85
85
  // on the host. Default: read-only (safe for chat). COS_CODEX_SANDBOX=workspace-write
86
- // permits writes within the working directory only. Full host access is
86
+ // permits writes within the working directory only, and enables outbound network
87
+ // inside that sandbox (Gmail API, HTTPS fetches) via
88
+ // `sandbox_workspace_write.network_access=true`. Full host filesystem access is
87
89
  // intentionally not exposed by this server.
88
90
  function codexSandboxMode(): 'workspace-write' | 'read-only' {
89
91
  return process.env.COS_CODEX_SANDBOX === 'workspace-write' ? 'workspace-write' : 'read-only'
90
92
  }
91
93
 
92
94
  function codexSandboxArgs(): string[] {
93
- return ['--sandbox', codexSandboxMode(), '--skip-git-repo-check']
95
+ const args = ['--sandbox', codexSandboxMode(), '--skip-git-repo-check']
96
+ // workspace-write keeps the filesystem boundary but leaves network OFF by
97
+ // default in Codex. Glasses outbound Gmail (and similar HTTPS) needs this
98
+ // explicit opt-in — without it agents correctly report "cannot reach
99
+ // gmail.googleapis.com" even when workspace writes work.
100
+ if (codexSandboxMode() === 'workspace-write') {
101
+ args.push('-c', 'sandbox_workspace_write.network_access=true')
102
+ }
103
+ return args
94
104
  }
95
105
 
96
106
  /**
@@ -101,7 +111,7 @@ function codexSandboxArgs(): string[] {
101
111
  function codexCapabilityPrompt(): string {
102
112
  if (codexSandboxMode() === 'workspace-write') {
103
113
  return `TOOL CAPABILITY CONTRACT:
104
- You are an AGENT model running \`codex exec --sandbox workspace-write\`. Reads, searches, shell commands, and writes inside the working directory are available to you whether or not any list names them. Never claim a tool is unavailable based on that list or on any header — PROBE first with one real call; only an attempted call that actually failed is evidence a capability is missing. Writes outside the working directory are denied by the sandbox; say that plainly if you hit it.
114
+ You are an AGENT model running \`codex exec --sandbox workspace-write\` with \`sandbox_workspace_write.network_access=true\`. Reads, searches, shell commands, outbound HTTPS (including Gmail API / cos_python network calls), and writes inside the working directory are available to you whether or not any list names them. Never claim a tool is unavailable based on that list or on any header — PROBE first with one real call; only an attempted call that actually failed is evidence a capability is missing. Writes outside the working directory are denied by the sandbox; say that plainly if you hit it. Prefer the COS Gmail API script path (\`email_cache\` / \`email_gmail_api\` with confirm=True) over approval-gated Google Workspace connector sends.
105
115
  ${TOOL_HONESTY_CLAUSE}
106
116
  ${UNTRUSTED_CONTENT_CLAUSE}`
107
117
  }
@@ -0,0 +1,221 @@
1
+ // Meeting HQ polish progress for COS Control / health.
2
+ // Written next to pending-batch audio so a draining Update can show % complete
3
+ // instead of a silent "degraded" row while Whisper chews through a long save.
4
+
5
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
+ import { basename, join } from 'node:path'
7
+ import { dataPath } from './data-dir.js'
8
+
9
+ export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
10
+ export const BATCH_PENDING_MARKER = '_batch_pending.marker'
11
+
12
+ export type MeetingBatchPhase =
13
+ | 'queued'
14
+ | 'hq_polish'
15
+ | 'quality_check'
16
+ | 'persisting'
17
+ | 'done'
18
+
19
+ export interface MeetingBatchProgress {
20
+ schemaVersion: 1
21
+ meetingId: string
22
+ phase: MeetingBatchPhase
23
+ segmentsDone: number
24
+ segmentsTotal: number
25
+ chunkFiles?: number
26
+ updatedAt: string
27
+ startedAt: string
28
+ }
29
+
30
+ export interface MeetingSyncMeeting {
31
+ meetingId: string
32
+ phase: MeetingBatchPhase | 'pending'
33
+ percent: number | null
34
+ segmentsDone: number | null
35
+ segmentsTotal: number | null
36
+ chunkFiles: number
37
+ label: string
38
+ updatedAt: string | null
39
+ }
40
+
41
+ export interface MeetingSyncSnapshot {
42
+ active: boolean
43
+ percent: number | null
44
+ label: string
45
+ blocksRestart: boolean
46
+ meetings: MeetingSyncMeeting[]
47
+ }
48
+
49
+ function pendingBatchRoot(): string {
50
+ return dataPath('pending-batch')
51
+ }
52
+
53
+ function clampPercent(done: number, total: number): number {
54
+ if (total <= 0) return 0
55
+ return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
56
+ }
57
+
58
+ function labelFor(meeting: Omit<MeetingSyncMeeting, 'label'>): string {
59
+ if (meeting.percent != null && meeting.segmentsTotal != null && meeting.segmentsTotal > 0) {
60
+ return `HQ polish ${meeting.percent}% (${meeting.segmentsDone}/${meeting.segmentsTotal})`
61
+ }
62
+ if (meeting.chunkFiles > 0) {
63
+ return `HQ polish · ${meeting.chunkFiles} chunk${meeting.chunkFiles === 1 ? '' : 's'}`
64
+ }
65
+ return 'HQ polish · pending'
66
+ }
67
+
68
+ export function writeMeetingBatchProgress(
69
+ audioDir: string,
70
+ input: {
71
+ phase: MeetingBatchPhase
72
+ segmentsDone: number
73
+ segmentsTotal: number
74
+ meetingId?: string
75
+ startedAt?: string
76
+ },
77
+ ): void {
78
+ const meetingId = input.meetingId ?? basename(audioDir)
79
+ const path = join(audioDir, BATCH_PROGRESS_FILENAME)
80
+ let startedAt = input.startedAt
81
+ if (!startedAt && existsSync(path)) {
82
+ try {
83
+ const prior = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchProgress
84
+ if (typeof prior.startedAt === 'string') startedAt = prior.startedAt
85
+ } catch { /* replace */ }
86
+ }
87
+ const payload: MeetingBatchProgress = {
88
+ schemaVersion: 1,
89
+ meetingId,
90
+ phase: input.phase,
91
+ segmentsDone: Math.max(0, input.segmentsDone),
92
+ segmentsTotal: Math.max(0, input.segmentsTotal),
93
+ updatedAt: new Date().toISOString(),
94
+ startedAt: startedAt ?? new Date().toISOString(),
95
+ }
96
+ try {
97
+ const wavs = readdirSync(audioDir).filter(name => name.endsWith('.wav')).length
98
+ payload.chunkFiles = wavs
99
+ } catch { /* optional */ }
100
+ try {
101
+ writeFileSync(path, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 })
102
+ } catch {
103
+ // Progress is observability only — never fail HQ polish for a status write.
104
+ }
105
+ }
106
+
107
+ export function clearMeetingBatchProgress(audioDir: string): void {
108
+ const path = join(audioDir, BATCH_PROGRESS_FILENAME)
109
+ try {
110
+ if (existsSync(path)) unlinkSync(path)
111
+ } catch { /* ignore */ }
112
+ }
113
+
114
+ function readProgressFile(dir: string): MeetingBatchProgress | null {
115
+ const path = join(dir, BATCH_PROGRESS_FILENAME)
116
+ if (!existsSync(path)) return null
117
+ try {
118
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchProgress
119
+ if (raw?.schemaVersion !== 1) return null
120
+ if (typeof raw.meetingId !== 'string') return null
121
+ if (typeof raw.segmentsTotal !== 'number' || typeof raw.segmentsDone !== 'number') return null
122
+ return raw
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ function markerFresh(dir: string, maxAgeMs = 15 * 60_000): boolean {
129
+ const marker = join(dir, BATCH_PENDING_MARKER)
130
+ if (!existsSync(marker)) return false
131
+ try {
132
+ return Date.now() - statSync(marker).mtimeMs <= maxAgeMs
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /** Snapshot of pending HQ polish work for /api/health and COS Control. */
139
+ export function getMeetingSyncSnapshot(
140
+ root: string = pendingBatchRoot(),
141
+ ): MeetingSyncSnapshot {
142
+ const meetings: MeetingSyncMeeting[] = []
143
+ if (!existsSync(root)) {
144
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
145
+ }
146
+
147
+ let dirs: string[] = []
148
+ try {
149
+ dirs = readdirSync(root).filter(name => {
150
+ try {
151
+ return statSync(join(root, name)).isDirectory()
152
+ } catch {
153
+ return false
154
+ }
155
+ })
156
+ } catch {
157
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
158
+ }
159
+
160
+ for (const name of dirs) {
161
+ const dir = join(root, name)
162
+ const progress = readProgressFile(dir)
163
+ let chunkFiles = 0
164
+ try {
165
+ chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
166
+ } catch { /* ignore */ }
167
+
168
+ const active = markerFresh(dir) || progress != null
169
+ if (!active && chunkFiles === 0) continue
170
+
171
+ if (progress) {
172
+ const percent = progress.segmentsTotal > 0
173
+ ? clampPercent(progress.segmentsDone, progress.segmentsTotal)
174
+ : null
175
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
176
+ meetingId: progress.meetingId || name,
177
+ phase: progress.phase,
178
+ percent,
179
+ segmentsDone: progress.segmentsTotal > 0 ? progress.segmentsDone : null,
180
+ segmentsTotal: progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
181
+ chunkFiles: progress.chunkFiles ?? chunkFiles,
182
+ updatedAt: progress.updatedAt,
183
+ }
184
+ meetings.push({ ...row, label: labelFor(row) })
185
+ continue
186
+ }
187
+
188
+ if (!markerFresh(dir) && chunkFiles === 0) continue
189
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
190
+ meetingId: name,
191
+ phase: 'pending',
192
+ percent: null,
193
+ segmentsDone: null,
194
+ segmentsTotal: null,
195
+ chunkFiles,
196
+ updatedAt: null,
197
+ }
198
+ meetings.push({ ...row, label: labelFor(row) })
199
+ }
200
+
201
+ if (meetings.length === 0) {
202
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
203
+ }
204
+
205
+ const withPercent = meetings.filter(m => m.percent != null)
206
+ const percent = withPercent.length === meetings.length
207
+ ? Math.round(withPercent.reduce((sum, m) => sum + (m.percent ?? 0), 0) / meetings.length)
208
+ : null
209
+
210
+ const label = meetings.length === 1
211
+ ? meetings[0]!.label
212
+ : `${meetings.length} meetings syncing` + (percent != null ? ` · ${percent}%` : '')
213
+
214
+ return {
215
+ active: true,
216
+ percent,
217
+ label,
218
+ blocksRestart: true,
219
+ meetings,
220
+ }
221
+ }
@@ -3,10 +3,14 @@
3
3
  // The candidate is never canonical until batch-transcript-quality accepts it.
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
6
- import { join, resolve } from 'node:path'
6
+ import { basename, join, resolve } from 'node:path'
7
7
  import { enhanceAudio } from './audio-enhance.js'
8
8
  import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
9
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
+ import {
11
+ clearMeetingBatchProgress,
12
+ writeMeetingBatchProgress,
13
+ } from './meeting-batch-progress.js'
10
14
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
11
15
  import {
12
16
  evaluateBatchQuality,
@@ -167,6 +171,13 @@ async function transcribeSegments(
167
171
  entries: IndexedTranscriptChunk[],
168
172
  ): Promise<BatchResult[]> {
169
173
  const results: BatchResult[] = []
174
+ const meetingId = basename(audioDir)
175
+ writeMeetingBatchProgress(audioDir, {
176
+ phase: 'hq_polish',
177
+ segmentsDone: 0,
178
+ segmentsTotal: segments.length,
179
+ meetingId,
180
+ })
170
181
  for (const segment of segments) {
171
182
  try {
172
183
  refreshPendingLease(audioDir)
@@ -202,11 +213,23 @@ async function transcribeSegments(
202
213
  speakerWords: mapWordsToSpeakers(words, segment, entries),
203
214
  })
204
215
  refreshPendingLease(audioDir)
216
+ writeMeetingBatchProgress(audioDir, {
217
+ phase: 'hq_polish',
218
+ segmentsDone: results.length,
219
+ segmentsTotal: segments.length,
220
+ meetingId,
221
+ })
205
222
  } catch (error) {
206
223
  console.error(
207
224
  `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} failed: `
208
225
  + `${error instanceof Error ? error.message : String(error)}`,
209
226
  )
227
+ writeMeetingBatchProgress(audioDir, {
228
+ phase: 'hq_polish',
229
+ segmentsDone: results.length,
230
+ segmentsTotal: segments.length,
231
+ meetingId,
232
+ })
210
233
  }
211
234
  }
212
235
  return results
@@ -223,13 +246,22 @@ export function runMeetingBatchPipeline(
223
246
  // Lease immediately, including time spent behind another HQ decoder. Without
224
247
  // this, the two-hour cleanup could delete a queued meeting before it starts.
225
248
  refreshPendingLease(audioDir)
249
+ writeMeetingBatchProgress(audioDir, {
250
+ phase: 'queued',
251
+ segmentsDone: 0,
252
+ segmentsTotal: 0,
253
+ meetingId: basename(audioDir),
254
+ })
226
255
  const lease = setInterval(() => refreshPendingLease(audioDir), 60_000)
227
256
  lease.unref()
228
257
  const job = batchQueueTail.then(() => runMeetingBatchPipelineNow(
229
258
  audioDir,
230
259
  entries,
231
260
  streamingWordCount,
232
- )).finally(() => clearInterval(lease))
261
+ )).finally(() => {
262
+ clearInterval(lease)
263
+ clearMeetingBatchProgress(audioDir)
264
+ })
233
265
  batchQueueTail = job.then(() => undefined, () => undefined)
234
266
  return job
235
267
  }
@@ -248,7 +280,19 @@ async function runMeetingBatchPipelineNow(
248
280
  const segments = segmentTranscriptChunks(entries)
249
281
  if (segments.length === 0) return { transcriptionQuality: 'streaming' }
250
282
 
283
+ writeMeetingBatchProgress(audioDir, {
284
+ phase: 'hq_polish',
285
+ segmentsDone: 0,
286
+ segmentsTotal: segments.length,
287
+ meetingId: basename(audioDir),
288
+ })
251
289
  const batchSegments = await transcribeSegments(audioDir, segments, entries)
290
+ writeMeetingBatchProgress(audioDir, {
291
+ phase: 'quality_check',
292
+ segmentsDone: segments.length,
293
+ segmentsTotal: segments.length,
294
+ meetingId: basename(audioDir),
295
+ })
252
296
  const batchTranscript = batchSegments.map(result => result.text).join(' ')
253
297
  const qualityReport = evaluateBatchQuality(batchSegments, streamingWordCount)
254
298
  if (!qualityReport.accepted) {
@@ -37,6 +37,7 @@ import { getServerGenerationId } from '../lib/managed-runtime.js'
37
37
  import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
38
38
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
39
39
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
40
+ import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
40
41
 
41
42
  export const healthRouter = Router()
42
43
 
@@ -259,6 +260,7 @@ healthRouter.get('/health', async (_req, res) => {
259
260
  // agent binary paths stay on the authenticated /api/models surface.
260
261
  const cursorSnapshot = getCursorModelCatalogSnapshot()
261
262
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
263
+ const meeting_sync = getMeetingSyncSnapshot()
262
264
  res.json({
263
265
  ...checks,
264
266
  server_version: managedServerVersion(),
@@ -273,6 +275,7 @@ healthRouter.get('/health', async (_req, res) => {
273
275
  tts_local,
274
276
  codex_models,
275
277
  cursor_models,
278
+ meeting_sync,
276
279
  capabilities: {
277
280
  transcription: { ...transcription, hq: transcriptionHq },
278
281
  recovery,