@gotcos/glasses-server 6.27.13 → 6.29.0

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,5 +1,34 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.28.0
4
+ - **Continue Original Agent Thread — attach to a live desktop thread and append a turn
5
+ to it.** From the glasses you can now continue a Claude Code or Codex conversation that
6
+ already exists on your Mac: the turn is written into the REAL transcript, not a copy.
7
+ Verified end to end on disposable threads for both providers.
8
+
9
+ **OFF BY DEFAULT and permanently supported that way.** Set `COS_THREAD_ATTACH_ENABLED=1`
10
+ to turn it on. With it unset you get exactly the behaviour that existed before: read-only
11
+ session browsing and Fork-only everywhere. The two write routes are not registered at all
12
+ when it is off, so a disabled server holds no reachable write code, and the attachability
13
+ endpoint answers `attach_disabled` without touching the filesystem.
14
+
15
+ - **A thread that someone has open on the desktop is never written to.** COS resolves, from
16
+ a first-party per-session registry, whether a live process owns the thread, and refuses
17
+ with "Open on your Mac. Fork it instead." Ownership of COS's own spawned child is
18
+ established from a measured kernel process start, because a bare pid can be recycled.
19
+
20
+ - **A repeated POST replays instead of delivering twice.** Turns carry a required client
21
+ idempotency key and completed or ambiguous outcomes are remembered durably, so a retry
22
+ after a lost response returns what the first turn did rather than posting a second copy
23
+ into the conversation. A pre-delivery refusal stays re-evaluatable.
24
+
25
+ - **A turn whose fate is unknown is never reported as a clean failure.** If the provider
26
+ fails after the prompt was delivered, the outcome is `ambiguous` with `retryable: false`
27
+ and copy telling you to check the thread, because a retry there would double-post.
28
+
29
+ - No always-approve, bypass-permissions or sandbox-escape flag can reach a provider running
30
+ an attached turn; the argv is checked before any process is created.
31
+
3
32
  ## 6.27.13
4
33
  - **`POST /api/meeting/:sessionId/backfill-enrolment`** — train a profile from a voice
5
34
  that was named BEFORE enrolment shipped. Those meetings have a correct transcript and
@@ -2190,6 +2219,36 @@ unsaved capture, and makes batch status stop lying about finished work.
2190
2219
 
2191
2220
  # Changelog
2192
2221
 
2222
+ ## [6.29.0] - 2026-08-16
2223
+
2224
+ ### Continue is queued instead of holding the phone
2225
+
2226
+ - An attached turn is admitted with **202** and delivered in the background. A
2227
+ provider turn runs up to 21 minutes and iOS suspends the WebView the moment the
2228
+ phone is pocketed, so a synchronous turn was lost exactly when the user did the
2229
+ natural thing.
2230
+ - Every safety gate stays synchronous: body, replay, queued-prompt, lease, target,
2231
+ epoch, fence, claim, occupancy, head baseline, pin. A refusal still reaches the
2232
+ user immediately and precisely. Only the spawn moved.
2233
+ - **New:** `GET /api/agent-sessions/bindings/:bindingId/turns/:clientTurnId`
2234
+ serves the same durable ledger record the replay path serves, so a poll and a
2235
+ retry can never disagree about what a turn did. Reports `unknown` rather than
2236
+ `failed` for a key it has never seen.
2237
+ - Fixed: a post-202 refusal recorded nothing, so the status route would have
2238
+ answered `pending` forever. Pre-202 refusals keep the old semantics and stay
2239
+ re-evaluatable.
2240
+
2241
+ ### Fork, ungated
2242
+
2243
+ - `POST /api/agent-sessions/:provider/:threadId/fork` is registered whether or not
2244
+ `COS_THREAD_ATTACH_ENABLED` is set. Fork appends to nothing: the original is left
2245
+ byte-identical. It is what every refusal tells the user to do instead, and a
2246
+ refusal pointing at a route that 404s is a dead end.
2247
+
2248
+ **Required by COS Glasses 6.8.363.** Continue still needs
2249
+ `COS_THREAD_ATTACH_ENABLED=1`; with it unset the write routes are not registered
2250
+ and behavior is unchanged.
2251
+
2193
2252
  ## 6.12.7
2194
2253
 
2195
2254
  Pairs the public server with COS Glasses build 227+ meeting follow-ups while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.13",
3
+ "version": "6.29.0",
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": {
package/server/index.ts CHANGED
@@ -20,6 +20,15 @@ import { transcribeRouter } from './routes/transcribe.js'
20
20
  import { sessionIndexRouter } from './routes/session-index.js'
21
21
  import { agentSessionsRouter } from './routes/agent-sessions.js'
22
22
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
23
+ import { createAgentSessionBindingsRouter } from './routes/agent-session-bindings.js'
24
+ import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
25
+ import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
26
+ import { realOccupancyDirs, realOccupancyProbes } from './lib/occupancy-probes.js'
27
+ import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
28
+ import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
29
+ import { forkThread, realForkDeps } from './lib/fork-thread.js'
30
+ import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
31
+ import { threadOccupancy } from './lib/thread-occupancy.js'
23
32
  import { displayRouter } from './routes/display.js'
24
33
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
25
34
  import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
@@ -263,6 +272,148 @@ app.use((_req, _res, next) => {
263
272
  next()
264
273
  })
265
274
 
275
+ // Hydrated once at boot, before any route can read it. Never throws; a corrupt
276
+ // or unreadable store yields a degraded registry rather than a silent empty one.
277
+ const agentSessionBindingRegistry = AgentSessionBindingRegistry.open()
278
+ // Boot reap, and it is load-bearing rather than housekeeping. A turn pins its
279
+ // binding and unpins in a `finally` that never runs if the process dies during the
280
+ // provider run — a window of up to the 21-minute attached timeout, and exactly what
281
+ // a crash, a force-quit, or a COS Control "Update Server" does. A pinned binding
282
+ // never expires by design, and `blocksTarget` refuses any pinned target, so the
283
+ // thread becomes permanently unattachable: measured at +1m, +31m, +2d, +40d and
284
+ // +400d, all `native_target_busy`. The refusal copy tells the user to detach the
285
+ // other chat, and this router registers no detach route, so the only recovery was
286
+ // hand-editing the store. `reap()` already fixes it and simply had no caller.
287
+ try {
288
+ const reaped = agentSessionBindingRegistry.reap(Date.now())
289
+ if (reaped.pinsDropped > 0 || reaped.removed.length > 0) {
290
+ console.log(`[binding-registry] boot reap: ${reaped.pinsDropped} stale pin(s) dropped, ${reaped.removed.length} binding(s) removed`)
291
+ }
292
+ } catch (error) {
293
+ console.warn('[binding-registry] boot reap failed', error)
294
+ }
295
+ // And periodically, so a strand created mid-run clears without waiting for the next
296
+ // restart. Unref'd so it never holds the process open during shutdown.
297
+ const bindingReapTimer = setInterval(() => {
298
+ try { agentSessionBindingRegistry.reap(Date.now()) } catch { /* next tick retries */ }
299
+ }, 10 * 60_000)
300
+ bindingReapTimer.unref()
301
+
302
+ // Built once. Each of these reads the disk, so sharing them keeps an attach from
303
+ // re-deriving roots per request.
304
+ const occupancyProbes = realOccupancyProbes(cosSpawnedPids)
305
+ const occupancyDirs = realOccupancyDirs()
306
+ const nativeHeadDeps = realNativeHeadDeps()
307
+ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
308
+
309
+ /**
310
+ * The shim between the route's request shape and the adapter's.
311
+ *
312
+ * The route was written against a contract that carries fingerprints and a veto
313
+ * hook; the adapter needs a real cwd, a permission policy, and its dependencies.
314
+ * Reconciling them is the composition root's job — putting it in either module
315
+ * would make one of them know about the other's shape.
316
+ *
317
+ * OWNERSHIP IS RECORDED EXACTLY ONCE, BY THE ROUTE.
318
+ *
319
+ * An earlier version of this comment claimed "exactly once" while the code did it
320
+ * twice: it called `recordCosSpawn` here and then `request.onSpawn(pid)`, and the
321
+ * route's `onSpawn` is not a veto — it re-probes the process start and records the
322
+ * claim itself (agent-session-bindings.ts:1395-1422). Measured, one turn produced
323
+ * `recorded +2 / released +1`. Not a leak, since both writes hit one Map key and a
324
+ * single release clears it, but it pinned `stats().recorded : released` at a
325
+ * permanent 2:1 — and that ratio is exactly what an operator reads to detect the
326
+ * leak this ledger exists to prevent, so the diagnostic was poisoned by the code
327
+ * meant to feed it.
328
+ *
329
+ * The route is the right owner: it holds the `recordedPids` list its own `finally`
330
+ * releases from. So this delegates rather than duplicating. Any return other than
331
+ * the exact string 'recorded' makes the adapter SIGKILL the child and release
332
+ * before a single prompt byte is written, which is the veto the route wants.
333
+ */
334
+ const deliverAttachedTurnForRoute = async (request: {
335
+ provider: 'claude' | 'codex'
336
+ nativeThreadId: string
337
+ prompt: string
338
+ onSpawn: (pid: number) => boolean
339
+ }): Promise<unknown> => {
340
+ // Resolved here, not stored on the binding: the cwd is read from the transcript,
341
+ // which records it verbatim. Decoding the project slug is lossy — this Mac's own
342
+ // workspace path contains both a space and hyphens.
343
+ const workspace = resolveAttachedWorkspace(
344
+ request.provider, request.nativeThreadId, attachedWorkspaceDeps,
345
+ )
346
+ // No cwd means no attach. Never fall back to the server's own working directory,
347
+ // which is wherever the LaunchAgent happened to start.
348
+ if (workspace === null) {
349
+ return { ok: false, delivery: 'not_attempted', reason: 'target_unresolvable' }
350
+ }
351
+
352
+ const base = realAttachedTurnDeps(() => {
353
+ // The final occupancy re-check, synchronous and immediately before spawn
354
+ // (plan 4.3 step 6). A newly appeared owner is terminal, not a warning.
355
+ const verdict = threadOccupancy(
356
+ request.provider, request.nativeThreadId, occupancyProbes, occupancyDirs,
357
+ )
358
+ return { attachable: verdict.attachable, reason: verdict.reason }
359
+ })
360
+
361
+ return deliverAttachedTurn({
362
+ provider: request.provider,
363
+ nativeThreadId: request.nativeThreadId,
364
+ prompt: request.prompt,
365
+ cwd: workspace.path,
366
+ // The only policy this build accepts. The adapter refuses anything else and
367
+ // asserts no bypass/always-approve flag reaches the argv (plan 4.7).
368
+ policy: 'read_only',
369
+ deps: {
370
+ ...base,
371
+ // `startMs` is deliberately unused: the adapter already probed it as a GATE
372
+ // (a null there aborts before this is reached), and the route probes again
373
+ // as the recorder. One record, one authority.
374
+ recordSpawn: (pid: number, _startMs: number) =>
375
+ request.onSpawn(pid) ? 'recorded' : 'route_refused_ownership',
376
+ },
377
+ })
378
+ }
379
+
380
+ /**
381
+ * The shim for FORK — the action seventeen refusal strings in this feature already
382
+ * recommend, and which until 6.29 existed nowhere in the server or the app.
383
+ *
384
+ * Deliberately much thinner than the attached shim above, and every difference is
385
+ * load-bearing:
386
+ *
387
+ * - NO PREFLIGHT. `realAttachedTurnDeps` takes an occupancy re-check because it is
388
+ * about to APPEND to a thread someone may have open on their desktop. A fork
389
+ * appends to nothing: verified byte-for-byte on both providers on 2026-08-16,
390
+ * source sha256 unchanged across the run. A live desktop owner must therefore
391
+ * NOT block a fork — gating it here would refuse at exactly the moment fork is
392
+ * the only path the user has left.
393
+ * - NO `onSpawn` HAND-BACK. The turns route owns its own ledger record because it
394
+ * holds the `recordedPids` list that its `finally` releases from. The fork route
395
+ * holds no such list, so `fork-thread.ts` records and releases the child itself,
396
+ * exactly once, on every exit path — which keeps the recorded:released ratio the
397
+ * operator reads for leak detection at 1:1.
398
+ * - A WATERMARK IS SUPPLIED, and it is the point. `nativeHead` of the SOURCE, read
399
+ * once before the spawn and once after the child settles, is what turns "a fork
400
+ * does not touch the original" from a promise into a checked property. It is
401
+ * SYNCHRONOUS (native-head.ts:491), which is what lets the module read the
402
+ * baseline at the spawn boundary without an await. Unwired, the module can only
403
+ * ever report `unverified`, and it would report that honestly rather than
404
+ * claiming a verification it never performed.
405
+ */
406
+ const forkThreadForRoute = (request: {
407
+ provider: 'claude' | 'codex'
408
+ nativeThreadId: string
409
+ prompt: string
410
+ cwd: string
411
+ policy: 'read_only'
412
+ }): Promise<unknown> => forkThread({
413
+ ...request,
414
+ deps: realForkDeps((provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps)),
415
+ })
416
+
266
417
  // API routes
267
418
  app.use('/api', healthRouter)
268
419
  app.use('/api', diagRouter)
@@ -280,6 +431,41 @@ app.use('/api', agentSessionsRouter)
280
431
  // Presence view of Claude Code sessions on this Mac. Dark unless
281
432
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
282
433
  app.use('/api', claudeSessionsRouter)
434
+ // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
435
+ // without colliding with a live writer? Read-only — it answers, it never attaches.
436
+ // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
437
+ // (`/agent-sessions/bindings`, `/agent-sessions/:provider/:threadId/attachability`)
438
+ // and cannot shadow that router's `/agent-sessions/:provider/:id` transcript route.
439
+ app.use('/api', createAgentSessionBindingsRouter({
440
+ probes: occupancyProbes,
441
+ dirs: occupancyDirs,
442
+ now: () => Date.now(),
443
+ resolveTarget: (provider, threadId) => {
444
+ const workspace = resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)
445
+ // Only the fingerprints cross this boundary. The route persists what it is
446
+ // given, and plan 3.3 keeps a filesystem path off anything client-visible.
447
+ return workspace === null ? null : {
448
+ workspaceFingerprint: workspace.workspaceFingerprint,
449
+ sourceFingerprint: workspace.sourceFingerprint,
450
+ }
451
+ },
452
+ nativeHead: (provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps),
453
+ deliverAttachedTurn: deliverAttachedTurnForRoute as never,
454
+ forkThread: forkThreadForRoute,
455
+ // The fork's real spawn directory. Separate from `resolveTarget` above, which
456
+ // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
457
+ // off anything client-visible. Null refuses: never fall back to the server's own
458
+ // working directory, which is wherever the LaunchAgent happened to start.
459
+ resolveForkWorkspace: (provider, threadId) =>
460
+ resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)?.path ?? null,
461
+ // One instance per process. The epoch high-water mark is only monotonic if a
462
+ // single reader owns the durable store, so this must never be constructed twice.
463
+ // `open()` never throws: an unreadable store yields a DEGRADED registry whose
464
+ // `available()` is false, which the route renders as a refusal rather than as an
465
+ // empty list — "nothing is bound" and "the store could not be read" must not
466
+ // look the same.
467
+ bindings: agentSessionBindingRegistry,
468
+ }))
283
469
  app.use('/api', displayRouter)
284
470
  app.use('/api', transcribeStreamRouter)
285
471
  app.use('/api', meetingRouter)