@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 +59 -0
- package/package.json +1 -1
- package/server/index.ts +186 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +2024 -0
- package/server/routes/health.ts +24 -0
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.
|
|
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)
|