@gotcos/glasses-server 6.27.13 → 6.28.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.13",
3
+ "version": "6.28.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,14 @@ 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 { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
30
+ import { threadOccupancy } from './lib/thread-occupancy.js'
23
31
  import { displayRouter } from './routes/display.js'
24
32
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
25
33
  import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
@@ -263,6 +271,111 @@ app.use((_req, _res, next) => {
263
271
  next()
264
272
  })
265
273
 
274
+ // Hydrated once at boot, before any route can read it. Never throws; a corrupt
275
+ // or unreadable store yields a degraded registry rather than a silent empty one.
276
+ const agentSessionBindingRegistry = AgentSessionBindingRegistry.open()
277
+ // Boot reap, and it is load-bearing rather than housekeeping. A turn pins its
278
+ // binding and unpins in a `finally` that never runs if the process dies during the
279
+ // provider run — a window of up to the 21-minute attached timeout, and exactly what
280
+ // a crash, a force-quit, or a COS Control "Update Server" does. A pinned binding
281
+ // never expires by design, and `blocksTarget` refuses any pinned target, so the
282
+ // thread becomes permanently unattachable: measured at +1m, +31m, +2d, +40d and
283
+ // +400d, all `native_target_busy`. The refusal copy tells the user to detach the
284
+ // other chat, and this router registers no detach route, so the only recovery was
285
+ // hand-editing the store. `reap()` already fixes it and simply had no caller.
286
+ try {
287
+ const reaped = agentSessionBindingRegistry.reap(Date.now())
288
+ if (reaped.pinsDropped > 0 || reaped.removed.length > 0) {
289
+ console.log(`[binding-registry] boot reap: ${reaped.pinsDropped} stale pin(s) dropped, ${reaped.removed.length} binding(s) removed`)
290
+ }
291
+ } catch (error) {
292
+ console.warn('[binding-registry] boot reap failed', error)
293
+ }
294
+ // And periodically, so a strand created mid-run clears without waiting for the next
295
+ // restart. Unref'd so it never holds the process open during shutdown.
296
+ const bindingReapTimer = setInterval(() => {
297
+ try { agentSessionBindingRegistry.reap(Date.now()) } catch { /* next tick retries */ }
298
+ }, 10 * 60_000)
299
+ bindingReapTimer.unref()
300
+
301
+ // Built once. Each of these reads the disk, so sharing them keeps an attach from
302
+ // re-deriving roots per request.
303
+ const occupancyProbes = realOccupancyProbes(cosSpawnedPids)
304
+ const occupancyDirs = realOccupancyDirs()
305
+ const nativeHeadDeps = realNativeHeadDeps()
306
+ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
307
+
308
+ /**
309
+ * The shim between the route's request shape and the adapter's.
310
+ *
311
+ * The route was written against a contract that carries fingerprints and a veto
312
+ * hook; the adapter needs a real cwd, a permission policy, and its dependencies.
313
+ * Reconciling them is the composition root's job — putting it in either module
314
+ * would make one of them know about the other's shape.
315
+ *
316
+ * OWNERSHIP IS RECORDED EXACTLY ONCE, BY THE ROUTE.
317
+ *
318
+ * An earlier version of this comment claimed "exactly once" while the code did it
319
+ * twice: it called `recordCosSpawn` here and then `request.onSpawn(pid)`, and the
320
+ * route's `onSpawn` is not a veto — it re-probes the process start and records the
321
+ * claim itself (agent-session-bindings.ts:1395-1422). Measured, one turn produced
322
+ * `recorded +2 / released +1`. Not a leak, since both writes hit one Map key and a
323
+ * single release clears it, but it pinned `stats().recorded : released` at a
324
+ * permanent 2:1 — and that ratio is exactly what an operator reads to detect the
325
+ * leak this ledger exists to prevent, so the diagnostic was poisoned by the code
326
+ * meant to feed it.
327
+ *
328
+ * The route is the right owner: it holds the `recordedPids` list its own `finally`
329
+ * releases from. So this delegates rather than duplicating. Any return other than
330
+ * the exact string 'recorded' makes the adapter SIGKILL the child and release
331
+ * before a single prompt byte is written, which is the veto the route wants.
332
+ */
333
+ const deliverAttachedTurnForRoute = async (request: {
334
+ provider: 'claude' | 'codex'
335
+ nativeThreadId: string
336
+ prompt: string
337
+ onSpawn: (pid: number) => boolean
338
+ }): Promise<unknown> => {
339
+ // Resolved here, not stored on the binding: the cwd is read from the transcript,
340
+ // which records it verbatim. Decoding the project slug is lossy — this Mac's own
341
+ // workspace path contains both a space and hyphens.
342
+ const workspace = resolveAttachedWorkspace(
343
+ request.provider, request.nativeThreadId, attachedWorkspaceDeps,
344
+ )
345
+ // No cwd means no attach. Never fall back to the server's own working directory,
346
+ // which is wherever the LaunchAgent happened to start.
347
+ if (workspace === null) {
348
+ return { ok: false, delivery: 'not_attempted', reason: 'target_unresolvable' }
349
+ }
350
+
351
+ const base = realAttachedTurnDeps(() => {
352
+ // The final occupancy re-check, synchronous and immediately before spawn
353
+ // (plan 4.3 step 6). A newly appeared owner is terminal, not a warning.
354
+ const verdict = threadOccupancy(
355
+ request.provider, request.nativeThreadId, occupancyProbes, occupancyDirs,
356
+ )
357
+ return { attachable: verdict.attachable, reason: verdict.reason }
358
+ })
359
+
360
+ return deliverAttachedTurn({
361
+ provider: request.provider,
362
+ nativeThreadId: request.nativeThreadId,
363
+ prompt: request.prompt,
364
+ cwd: workspace.path,
365
+ // The only policy this build accepts. The adapter refuses anything else and
366
+ // asserts no bypass/always-approve flag reaches the argv (plan 4.7).
367
+ policy: 'read_only',
368
+ deps: {
369
+ ...base,
370
+ // `startMs` is deliberately unused: the adapter already probed it as a GATE
371
+ // (a null there aborts before this is reached), and the route probes again
372
+ // as the recorder. One record, one authority.
373
+ recordSpawn: (pid: number, _startMs: number) =>
374
+ request.onSpawn(pid) ? 'recorded' : 'route_refused_ownership',
375
+ },
376
+ })
377
+ }
378
+
266
379
  // API routes
267
380
  app.use('/api', healthRouter)
268
381
  app.use('/api', diagRouter)
@@ -280,6 +393,34 @@ app.use('/api', agentSessionsRouter)
280
393
  // Presence view of Claude Code sessions on this Mac. Dark unless
281
394
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
282
395
  app.use('/api', claudeSessionsRouter)
396
+ // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
397
+ // without colliding with a live writer? Read-only — it answers, it never attaches.
398
+ // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
399
+ // (`/agent-sessions/bindings`, `/agent-sessions/:provider/:threadId/attachability`)
400
+ // and cannot shadow that router's `/agent-sessions/:provider/:id` transcript route.
401
+ app.use('/api', createAgentSessionBindingsRouter({
402
+ probes: occupancyProbes,
403
+ dirs: occupancyDirs,
404
+ now: () => Date.now(),
405
+ resolveTarget: (provider, threadId) => {
406
+ const workspace = resolveAttachedWorkspace(provider, threadId, attachedWorkspaceDeps)
407
+ // Only the fingerprints cross this boundary. The route persists what it is
408
+ // given, and plan 3.3 keeps a filesystem path off anything client-visible.
409
+ return workspace === null ? null : {
410
+ workspaceFingerprint: workspace.workspaceFingerprint,
411
+ sourceFingerprint: workspace.sourceFingerprint,
412
+ }
413
+ },
414
+ nativeHead: (provider, threadId) => nativeHead(provider, threadId, nativeHeadDeps),
415
+ deliverAttachedTurn: deliverAttachedTurnForRoute as never,
416
+ // One instance per process. The epoch high-water mark is only monotonic if a
417
+ // single reader owns the durable store, so this must never be constructed twice.
418
+ // `open()` never throws: an unreadable store yields a DEGRADED registry whose
419
+ // `available()` is false, which the route renders as a refusal rather than as an
420
+ // empty list — "nothing is bound" and "the store could not be read" must not
421
+ // look the same.
422
+ bindings: agentSessionBindingRegistry,
423
+ }))
283
424
  app.use('/api', displayRouter)
284
425
  app.use('/api', transcribeStreamRouter)
285
426
  app.use('/api', meetingRouter)