@gotcos/glasses-server 6.27.12 → 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 +56 -0
- package/package.json +1 -1
- package/server/index.ts +141 -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/meeting-relabel-enrolment.ts +10 -1
- 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-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +1550 -0
- package/server/routes/meeting.ts +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
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
|
+
|
|
32
|
+
## 6.27.13
|
|
33
|
+
- **`POST /api/meeting/:sessionId/backfill-enrolment`** — train a profile from a voice
|
|
34
|
+
that was named BEFORE enrolment shipped. Those meetings have a correct transcript and
|
|
35
|
+
no profile, and re-running the rename cannot help: the voice is already a real name
|
|
36
|
+
there, so the placeholder guard correctly declines. The live case is Kirstyn Blum —
|
|
37
|
+
60 and 109 chunks across two meetings, 182 sidecar mentions, absent from a 77-profile
|
|
38
|
+
store.
|
|
39
|
+
- The correction LEDGER already holds what enrolment needs: the original `from`, the
|
|
40
|
+
`to`, and the exact chunk indices written at apply time. This replays those rows
|
|
41
|
+
through the SAME `enrolNamedVoice` — raw-index mapping, refusal when unmappable,
|
|
42
|
+
coherence, the diversity cap and the `correction:<sessionId>` tag all apply
|
|
43
|
+
identically. No second implementation to drift.
|
|
44
|
+
- **Named-source corrections are excluded, by the existing rule rather than a new one.**
|
|
45
|
+
`Ext -> Kirstyn` is training data; `Allison Wheeler -> Kirstyn` is a mis-attribution
|
|
46
|
+
fix, and training on it would put Allison's voice into Kirstyn's profile. Both rows
|
|
47
|
+
are reported; only the placeholder one attempts anything. Mutation-verified: dropping
|
|
48
|
+
the placeholder guard fails that test.
|
|
49
|
+
- **Runs in-process, which is the point.** The voice store is owned by the running
|
|
50
|
+
server and rewritten wholesale, so an external process that enrols directly has its
|
|
51
|
+
work silently clobbered. Not hypothetical — an attempt on 2026-08-13 validated
|
|
52
|
+
cleanly, selected 20 samples, and left the store untouched at its Aug 7 mtime.
|
|
53
|
+
- **Fails closed.** Without `confirm: true` it reports what it would enrol and writes
|
|
54
|
+
nothing. The preview runs every gate — a preview that skipped them would be a guess
|
|
55
|
+
about what the real call does — so `enrolNamedVoice` gained a `dryRun` flag. The
|
|
56
|
+
projection can land HIGHER than reality, because only `enrollEmbedding` can judge
|
|
57
|
+
near-duplicates against the live profile; that is documented on the flag.
|
|
58
|
+
|
|
3
59
|
## 6.27.12
|
|
4
60
|
- **Naming an unidentified voice creates a real speaker profile — correctly this time.**
|
|
5
61
|
Re-enables what 6.27.10 shipped broken and 6.27.11 disabled.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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)
|