@gotcos/glasses-server 6.58.0 → 6.58.2

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,3 +1,28 @@
1
+ ## 6.58.2
2
+
3
+ ### A New session from Work is named after its task, and visible while it runs
4
+
5
+ - Miles, 2026-09-29 at 20:40, on the first COS Control 0.5.249 New session (job c3878966, 20:36:49 to 20:39:11): Control opened it in Claude 31 s after the run ended, and it sat at the top of the MU-Chief-Staff sidebar as "General coding session". "This is super generic. It should be based off of the same name of the task that we're sending in." And while it ran: "I don't see anything in the sidebar ... We should probably show the session behind this." The card said "session live status unavailable" and Sessions listed the run as a COS server job.
6
+ - **A new Claude session is named.** A query job may carry `sessionName`. The server cleans it: control characters, zero-width spaces and direction marks and overrides become spaces (the joiners U+200C and U+200D stay), whitespace collapses, and a name over 100 characters is cut at 100, back to its last space when that space is in the second half; the cut counts whole characters, so it never splits an emoji. When the job starts a Claude session of its own, the server runs `claude --name=<name>`, which writes the session's title; the Claude app shows it in the sidebar. Never on a Continue into a session the COS session already has, never on a read-only dispatch, and a named session never takes the session the server warmed at boot (that one opens with "ready" under the glasses prompt); it starts clean. The server asks `claude --help` once whether the installed CLI takes `--name`; one that does not runs the session unnamed, as before. The name is outside the request fingerprint, so a retry that renames the same job is the same job, and an older server drops the field.
7
+ - **The job names its Claude session while it runs.** Claude announces its session id in its first stream event (a hook or init line, before any answer text); before this the server recorded it only with the final result, so a New session from Work could not be linked, shown or opened while it ran. The server now records it on the run and on the job (`cliSessionId`, through the same link update as the provider process) from that first event, only while the job is running; a job that already ended keeps what it had. At completion the result's id is recorded as before. Claude only: Codex, Cursor and Ollama jobs are unchanged.
8
+ - **Session titles in long transcripts.** The session list reads a Claude session's custom title from the end of its transcript; a transcript longer than the 256 KB window whose only title sits near the top now falls back to the top.
9
+ - Proven on this Mac first (claude 2.1.285): `claude -p --name` writes `{"type":"custom-title",...}` into the transcript (and again as the session goes on), the same line the Claude app shows as a session's name; `--resume <id> --name` renames; a name that starts with a dash, in the `--name=` form, is taken as the name; and every stream event, from the first hook line, carries the session id.
10
+ - Tests: the name's cleaning, joiners kept, the cut (at the last space, exactly at the limit, a run of emoji and of joined family emoji cut in whole characters), the request carrying it outside the fingerprint; `--name=` only for a session the COS session starts (not a Continue, not a dispatch, not the warmed session, not on a CLI without the option, a dash-leading name kept as one argument); the help probe (asked once, a CLI or help failure is a no); the first event with an id linking once (not an event without one, not a dispatch); the job showing the session while still running and keeping it at completion; a link after the job ended changing nothing; the runtime passing the name down and the early id up; a title at either end of a long transcript.
11
+
12
+ ## 6.58.1
13
+
14
+ ### A copy that lost its audio no longer holds the ring
15
+
16
+ - 2026-09-29, 07:36 to 07:51, glasses 6.9.558 on 6.57.1, the HC standup while driving: a hidden copy of COS (`mumnr7cf`) lost its audio at 07:38:19 (`meeting.audio.silent`, the bridge probe alive, the glasses connected) and went on claiming `recording: true`. Its timers were throttled: 24 log rows in 14 minutes, gaps of 30 s to 208 s. A new copy started the meeting at 07:38:48 and another at 07:45:10; each was told to yield within a second, which stopped its recording. Only the copy opened at 07:50:46 took the ring. About 12.5 minutes (07:38:19 to 07:51:02) never reached the Mac.
17
+ - The cause: an owner's own `recording` word held the ring for as long as it kept claiming (QA round 3's allowance for uploads that lag after an outage), and nothing checked the word against its chunks.
18
+ - **A copy that says it is not recording is believed over its own chunks.** Before, any chunk of its own in the last 30 s held the ring, including chunks committed before its audio stopped and still uploading or replayed. This applies to a claimant taking the ring, and to an owner holding it while that owner keeps claiming (45 s): a copy's start announcement is one claim with a 1.5 s deadline while its chunks get 30 s, so an older `false` can outlive a start the Mac never heard, and then the chunks speak for it. Glasses 6.9.560 says so within seconds of losing its audio. Only a claim that carries the `recording` field counts (glasses 6.9.510+); older copies keep the 6.50.4 rule.
19
+ - **A copy's word alone holds for 150 s (`CLIENT_INSTANCE_WORD_HOLD_MS`) after its newest proof:** its own newest chunk, the start of its recording claims, or a grace after a gap. The worst gap between two chunks from a copy that is recording is one failed upload (the 30 s timeout) plus the longest backoff (60 s); the hold allows one more backoff on top, and is derived from those two constants. After a gap of more than two ticks (`CLIENT_INSTANCE_GRACE_GAP_MS`, an outage or a sleep) a copy still recording gets one fresh hold for its uploads to catch up, and another only after a chunk of its own has arrived since: the 9/29 zombie's gappy claims would otherwise have renewed it forever. The same test applies to a copy taking the ring and to one holding it.
20
+ - **What each copy has said is kept per copy**, across changes of owner. Without that, a copy that lost the ring and took it back started a fresh clock, and a silent copy and an idle newer one traded the ring every few minutes for a whole meeting. Now the ring moves once. Glasses 6.9.560 then stands aside quietly, keeping its meeting until its audio returns or another copy records.
21
+ - **Known limit for builds before 6.9.560:** once the ring has moved, the silent copy keeps its meeting and, told to yield by an owner that is not recording, keeps answering the ring (6.9.510's rule) alongside the new owner until that owner starts recording, when it is told to yield and saves. The Mac could end that sooner only by telling it the new owner records when it does not.
22
+ - For the builds already out (the Even Hub 6.9.538 included), the hold is bounded, not removed: a copy opened while the silent copy's last chunks are under 30 s old, or its word under 150 s (plus one grace), is still told to yield. From then on the next copy to start the meeting keeps it.
23
+ - Unchanged: a newer copy still waits while the owner's own chunks arrive, a duplicate recording still ends on the claimant, untagged chunks still hold only for an owner that claims, and the 6.52.3 reclaim rules.
24
+ - Tests: the 9/29 timeline over HTTP (the new copy keeps the ring, and the zombie is then told to yield with `captureLive: true`); an older build that never stops saying `recording`, held for exactly the hold after its last chunk; a silent 6.9.538 copy and an idle newer one for 20 minutes (the ring moves once); a real recorder uploading on the worst cadence keeps the ring; a claimant that says `false` never takes on its chunks; a copy with nothing to show cannot take the ring back by saying `recording`; the hold's derivation; the one grace per chunk. Two HTTP fixtures that claimed to be 6.9.509 now omit `recording` as 6.9.509 did. Two review rounds (round 1 FIX FIRST: a claimant's own chunks and a fresh clock on every take-back let the ring alternate, and the 90 s hold had no margin over the uploader; round 2 FIX FIRST: believing an owner's old `false` forever let a second copy record alongside one whose start the Mac missed; all fixed, with a test for each).
25
+
1
26
  ## 6.58.0
2
27
 
3
28
  ### Work: did the session finish the task?
@@ -34,3 +34,5 @@ The journal is `work-intake/intake.json` in the server data directory, owned by
34
34
  Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{ reviewId, sessions }` for a meeting review (`reviewId` = `wr_` + 32 hex). The server reads the review's meeting title and reviewed follow-up from its own store; the client never sends work text. Errors: 400 `invalid_review_request` (bad id or session list), 404 `review_not_found`, 404 `reviews_unavailable` (reviews off), 503 `review_store_unavailable` (defensive; the stock lookup reads memory).
35
35
 
36
36
  Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
37
+
38
+ Since 6.58.2 `POST /api/query-jobs` also takes an optional `sessionName` (a string; the server cleans it as the 6.58.2 changelog says and keeps at most 100 characters; anything else is no name, never an error). It names only a Claude session the job starts itself (`claude --name=<name>`, when the installed CLI lists `--name`); a Continue, a dispatch, a Codex, Cursor or Ollama job, and an older server ignore it, and it is not part of the request fingerprint. A Claude job's snapshot carries `cliSessionId` from Claude's first stream event while the job is still `running`, not only at completion. COS Control 0.5.250 sends the Work item's title for New session, Start work and Fork to Claude, and links its receipt as soon as the running job names the session.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.58.0",
3
+ "version": "6.58.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -580,7 +580,17 @@ export function parseJsonLine(line: string): Record<string, unknown> | null {
580
580
  }
581
581
 
582
582
  export async function lastCustomTitle(path: string): Promise<string | null> {
583
- const text = await readWindow(path, true)
583
+ // The CLI re-writes its custom-title line as a session goes on, so the tail normally holds the
584
+ // newest. 6.58.2: a transcript longer than the window whose only title sits near the top (a
585
+ // `claude -p --name` run that wrote a long answer) falls back to the head.
586
+ const tail = customTitleIn(await readWindow(path, true))
587
+ if (tail) return tail
588
+ const st = await fileStat(path)
589
+ if (!st?.isFile || st.size <= HEAD_BYTES) return null
590
+ return customTitleIn(await readWindow(path, false))
591
+ }
592
+
593
+ function customTitleIn(text: string): string | null {
584
594
  let found: string | null = null
585
595
  for (const line of text.split('\n')) {
586
596
  if (!line.includes('custom-title')) continue
@@ -60,6 +60,7 @@ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
60
60
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
61
61
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
62
62
  import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
63
+ import { claudeSupportsSessionName } from './claude-cli-support.js'
63
64
 
64
65
  // Inactivity = no stdout data for this long → kill (catches stalls)
65
66
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -340,6 +341,12 @@ export interface StreamCallbacks {
340
341
  /** 6.52.0: readable steps of a durable Messages job (lib/job-trail.ts). Optional, and
341
342
  * absent on every path that is not a durable job with the trail on. */
342
343
  onTrail?: (draft: JobTrailDraft) => void
344
+ /**
345
+ * 6.58.2: the provider named its own session, at the start of the run. Claude announces
346
+ * its session id in the first stream event; before this the id reached the job only with
347
+ * the result, so COS Control could not show a New session from Work while it ran.
348
+ */
349
+ onNativeSession?: (linkage: { cliSessionId: string }) => void
343
350
  }
344
351
 
345
352
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
@@ -395,6 +402,8 @@ export interface CallOptions {
395
402
  sessionLockHeld?: boolean
396
403
  /** Read-only scheduled-task dispatch. Same shape as QueryJobRequest.dispatch. */
397
404
  dispatch?: { restricted: true; tools: readonly string[] }
405
+ /** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
406
+ sessionName?: string
398
407
  }
399
408
 
400
409
  export async function callClaudeStreaming(
@@ -426,6 +435,9 @@ export async function callClaudeStreaming(
426
435
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
427
436
  const resolvedCliKey = cliSessionKey(sid, resolvedModel)
428
437
  let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
438
+ // 6.58.2: decided before the pre-warmed session below stands in: a COS session that has no
439
+ // Claude session of its own yet starts one here, and only that one may be named.
440
+ const startsOwnCliSession = !existingCliSession
429
441
  callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
430
442
  clientJobId: options?.clientJobId,
431
443
  generation: options?.generation,
@@ -531,7 +543,10 @@ export async function callClaudeStreaming(
531
543
  // Check if we have a prior CLI session for this COS session.
532
544
  // If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
533
545
  // Restricted dispatch never steals the pre-warmed unrestricted session.
534
- if (!options?.dispatch && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
546
+ // 6.58.2: nor does a named New session (COS Control Work). The warmed session opens with the
547
+ // boot prompt "ready" under the glasses prompt; a session someone will open in the Claude app
548
+ // under its task's name starts clean.
549
+ if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
535
550
  // Only the default model consumes the pre-warmed session.
536
551
  // Hey Even (Haiku) cold-starts its own session to avoid model contamination.
537
552
  existingCliSession = preWarmedCliSessionId
@@ -576,6 +591,11 @@ export async function callClaudeStreaming(
576
591
  // Resume prior CLI session — reuses cached context, avoids cold start
577
592
  args.push('--resume', existingCliSession)
578
593
  }
594
+ // 6.58.2: name the session this COS session starts (never a Continue, never a dispatch), when
595
+ // the installed CLI takes --name. The `=` form keeps a name that starts with a dash a value.
596
+ if (startsOwnCliSession && !options?.dispatch && options?.sessionName && await claudeSupportsSessionName()) {
597
+ args.push(`--name=${options.sessionName}`)
598
+ }
579
599
 
580
600
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
581
601
  const env = { ...process.env }
@@ -856,6 +876,8 @@ export async function callClaudeStreaming(
856
876
 
857
877
  // ─── Process stdout ───
858
878
 
879
+ // 6.58.2: onNativeSession fires once per run.
880
+ let nativeSessionAnnounced = false
859
881
  proc.stdout.on('data', (chunk: Buffer) => {
860
882
  resetInactivity()
861
883
  buffer += chunk.toString()
@@ -871,6 +893,15 @@ export async function callClaudeStreaming(
871
893
  try {
872
894
  const event = JSON.parse(trimmed)
873
895
 
896
+ // 6.58.2: the first event that carries the session id (a hook or init line, before any
897
+ // answer text) links the run and the job to it. At completion the result's id is
898
+ // recorded as before, so the finished job names the session the result names.
899
+ if (!nativeSessionAnnounced && !options?.dispatch && typeof event.session_id === 'string' && event.session_id) {
900
+ nativeSessionAnnounced = true
901
+ updateClaudeRun(run.runId, { cliSessionId: event.session_id })
902
+ callbacks.onNativeSession?.({ cliSessionId: event.session_id })
903
+ }
904
+
874
905
  if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
875
906
  updateClaudeRun(run.runId, { resolvedModelId: event.model })
876
907
  } else if (event.type === 'stream_event') {
@@ -0,0 +1,37 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /**
4
+ * 6.58.2: does the installed Claude CLI take `--name`? A CLI that predates the option rejects
5
+ * it as unknown and the whole run fails, so the server asks `claude --help` once, in the
6
+ * background (it answers in about 50 ms on this Mac), and names a session only when the help
7
+ * lists the option. Any failure to ask is a no: the session then runs unnamed, as before 6.58.2.
8
+ */
9
+ let sessionNameSupport: Promise<boolean> | null = null
10
+
11
+ const HELP_TIMEOUT_MS = 5_000
12
+
13
+ export function claudeSupportsSessionName(): Promise<boolean> {
14
+ if (!sessionNameSupport) {
15
+ sessionNameSupport = new Promise<boolean>(resolve => {
16
+ try {
17
+ execFile('claude', ['--help'], { timeout: HELP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
18
+ const supported = !error && helpListsNameOption(`${stdout}\n${stderr}`)
19
+ if (!supported) console.warn('[claude-bridge] this Claude CLI does not list --name; new sessions stay unnamed')
20
+ resolve(supported)
21
+ })
22
+ } catch {
23
+ resolve(false)
24
+ }
25
+ })
26
+ }
27
+ return sessionNameSupport
28
+ }
29
+
30
+ /** The help line reads `-n, --name <name>` on Claude Code 2.1.285. */
31
+ export function helpListsNameOption(help: string): boolean {
32
+ return /(^|[\s,])--name[\s=<[]/m.test(help)
33
+ }
34
+
35
+ export function resetClaudeCliSupportForTests(): void {
36
+ sessionNameSupport = null
37
+ }
@@ -35,6 +35,28 @@ export const CLIENT_INSTANCE_AWAKE_MS = CLIENT_INSTANCE_LIVE_MS
35
35
  * intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
36
36
  */
37
37
  export const CLIENT_INSTANCE_CAPTURE_LIVE_MS = 30_000
38
+ /** The glasses uploader's longest wait between chunk attempts (cos-glasses-app local-first-meeting-uploader.ts, maxBackoffMs). */
39
+ export const GLASSES_UPLOAD_MAX_BACKOFF_MS = 60_000
40
+ /** The glasses chunk upload's request timeout (cos-glasses-app api-client.ts). A failed attempt can take this long. */
41
+ export const GLASSES_UPLOAD_TIMEOUT_MS = 30_000
42
+ /**
43
+ * 6.58.1: how long a copy's own `recording` word holds without proof. On 2026-09-29 (glasses
44
+ * 6.9.558) a hidden copy lost its audio at 07:38:19 and kept claiming `recording: true` for 12
45
+ * minutes; two newer copies started the meeting and were each told to stop within a second, and
46
+ * 12.5 minutes were never captured. The word exists for upload lag (QA round 3), which is bounded:
47
+ * the worst gap between two chunk arrivals from a copy that is recording is one failed attempt
48
+ * (the timeout) plus the longest backoff, 90 s. This allows one more backoff on top (QA, 6.58.1:
49
+ * exactly 90 s left no margin for a weak link, an ASR stall on the Mac or throttled timers).
50
+ */
51
+ export const CLIENT_INSTANCE_WORD_HOLD_MS = GLASSES_UPLOAD_TIMEOUT_MS + 2 * GLASSES_UPLOAD_MAX_BACKOFF_MS
52
+ /**
53
+ * 6.58.1: a gap between an owner's claims longer than two ticks is an outage or a sleep. When the
54
+ * owner comes back still saying `recording`, its word gets one fresh CLIENT_INSTANCE_WORD_HOLD_MS
55
+ * for its chunks to catch up, but only if a chunk of its own arrived since the last such grace:
56
+ * the 2026-09-29 zombie's timers were throttled (gaps of 30 s to 208 s between rows), so a grace
57
+ * on every gap would have held the ring for it indefinitely.
58
+ */
59
+ export const CLIENT_INSTANCE_GRACE_GAP_MS = 2 * CLIENT_INSTANCE_TICK_MS
38
60
  /** App copies whose newest chunk is remembered; the least recent is dropped past this. */
39
61
  export const CLIENT_INSTANCE_MAX_TAGGED = 64
40
62
  /** A boot time this far past the server clock is a phone clock set wrong, not a newer copy. */
@@ -45,15 +67,41 @@ export interface ClientInstanceClaim {
45
67
  bootAt: number
46
68
  version: string
47
69
  /**
48
- * The copy says it is recording (glasses 6.9.509+; false when absent). Chunk arrival
70
+ * The copy says it is recording (glasses 6.9.510+; false when absent). Chunk arrival
49
71
  * alone lags: after an outage the uploader backs off up to 60 s, and only a visible page
50
72
  * or a display reconnect resets it, which a hidden recording copy never gets (QA round 3).
51
73
  */
52
74
  recording: boolean
75
+ /**
76
+ * 6.58.1: the claim carried a `recording` field at all (glasses 6.9.510+). Only then is a
77
+ * `recording: false` a statement; from older copies it is merely absent.
78
+ */
79
+ recordingReported?: boolean
80
+ }
81
+
82
+ /**
83
+ * 6.58.1: what one copy has said about recording, kept per copy (not per owner) so it survives
84
+ * the ring moving between copies. Before this was per copy, a copy that lost the ring and took
85
+ * it back started a fresh clock each time, and a silent 6.9.538 copy and an idle newer one
86
+ * traded the ring every few minutes for a whole meeting, both answering the ring (QA, 6.58.1).
87
+ */
88
+ export interface CopyWord {
89
+ recording: boolean
90
+ recordingReported: boolean
91
+ /** When this copy's current run of `recording: true` claims began. */
92
+ recordingSince?: number
93
+ /** When this copy last got a grace after a gap in its claims (see CLIENT_INSTANCE_GRACE_GAP_MS). */
94
+ graceAt?: number
95
+ /** When this copy last claimed, under any verdict. */
96
+ seenAt: number
53
97
  }
54
98
 
55
99
  export interface ClientInstanceOwner extends ClientInstanceClaim {
56
100
  seenAt: number
101
+ /** 6.58.1: the owner's CopyWord.recordingSince. */
102
+ recordingSince?: number
103
+ /** 6.58.1: the owner's CopyWord.graceAt. */
104
+ graceAt?: number
57
105
  }
58
106
 
59
107
  export type ClientInstanceVerdict = 'owner' | 'yield'
@@ -75,6 +123,10 @@ export interface CaptureEvidence {
75
123
  owner: boolean
76
124
  claimant: boolean
77
125
  untagged: boolean
126
+ /** 6.58.1: when the owner's own newest tagged chunk arrived, however long ago; absent: never (or forgotten). */
127
+ ownerChunkAt?: number
128
+ /** 6.58.1: the same for the claimant. */
129
+ claimantChunkAt?: number
78
130
  }
79
131
 
80
132
  export const NO_CAPTURE: CaptureEvidence = { owner: false, claimant: false, untagged: false }
@@ -97,10 +149,14 @@ export class ClientChunkLedger {
97
149
 
98
150
  evidence(device: string, ownerId: string, claimId: string, now: number): CaptureEvidence {
99
151
  const live = (at: number | undefined) => at !== undefined && now - at < CLIENT_INSTANCE_CAPTURE_LIVE_MS
152
+ const ownerChunkAt = this.tagged.get(ownerId)
153
+ const claimantChunkAt = this.tagged.get(claimId)
100
154
  return {
101
- owner: live(this.tagged.get(ownerId)),
102
- claimant: live(this.tagged.get(claimId)),
155
+ owner: live(ownerChunkAt),
156
+ claimant: live(claimantChunkAt),
103
157
  untagged: live(this.untagged.get(device)),
158
+ ...(ownerChunkAt !== undefined ? { ownerChunkAt } : {}),
159
+ ...(claimantChunkAt !== undefined ? { claimantChunkAt } : {}),
104
160
  }
105
161
  }
106
162
  }
@@ -111,10 +167,68 @@ export class ClientChunkLedger {
111
167
  * own last claim saying `recording`, and untagged chunks from the device (which could be
112
168
  * anyone's), count only while it is still claiming: a dead owner must never be pinned by
113
169
  * a stale flag or by the chunks of the copy that replaced it (QA round 2).
170
+ *
171
+ * 6.58.1, two limits from the 2026-09-29 zombie (see CLIENT_INSTANCE_WORD_HOLD_MS):
172
+ * - An owner still claiming that SAYS it is not recording is believed over its trailing chunks.
173
+ * Glasses 6.9.560 says so within seconds of its audio stopping; the chunks it had already
174
+ * committed can keep arriving and would hold the ring for a copy with nothing left to record.
175
+ * Only while it claims (CLIENT_INSTANCE_LIVE_MS): a copy's start announcement is one claim with
176
+ * a 1.5 s deadline while its chunks get 30 s, so a `false` from before a meeting can outlive a
177
+ * start the Mac never heard, and its chunks must then speak for it (QA round 2: believing the
178
+ * old `false` let a second copy record alongside). Only a claim that carries the field counts.
179
+ * - Its word alone holds for CLIENT_INSTANCE_WORD_HOLD_MS after its newest proof (wordBacked).
114
180
  */
115
181
  export function ownerIsRecording(owner: ClientInstanceOwner, evidence: CaptureEvidence, now: number): boolean {
116
- if (evidence.owner) return true
117
- return (owner.recording || evidence.untagged) && now - owner.seenAt <= CLIENT_INSTANCE_LIVE_MS
182
+ const claiming = now - owner.seenAt <= CLIENT_INSTANCE_LIVE_MS
183
+ if (evidence.owner && !(claiming && saysNotRecording(owner))) return true
184
+ if (!claiming) return false
185
+ if (evidence.untagged) return true
186
+ return wordBacked(owner, evidence.ownerChunkAt, now)
187
+ }
188
+
189
+ /** 6.58.1: the claim carried `recording: false` explicitly (glasses 6.9.510+). */
190
+ export function saysNotRecording(claim: Pick<ClientInstanceClaim, 'recording' | 'recordingReported'>): boolean {
191
+ return claim.recordingReported === true && !claim.recording
192
+ }
193
+
194
+ /**
195
+ * 6.58.1: a copy's own `recording: true` still counts: its newest proof (its own newest chunk,
196
+ * the start of its run of recording claims, or its one grace after a gap) is no older than
197
+ * CLIENT_INSTANCE_WORD_HOLD_MS. The same rule for an owner holding the ring and a claimant
198
+ * taking it, so a copy with nothing to show cannot take the ring back by saying so (QA, 6.58.1).
199
+ */
200
+ export function wordBacked(word: Pick<CopyWord, 'recording' | 'recordingSince' | 'graceAt'>, chunkAt: number | undefined, now: number): boolean {
201
+ if (!word.recording) return false
202
+ const provenAt = Math.max(chunkAt ?? 0, word.recordingSince ?? 0, word.graceAt ?? 0)
203
+ return now - provenAt <= CLIENT_INSTANCE_WORD_HOLD_MS
204
+ }
205
+
206
+ /**
207
+ * 6.58.1: a copy's word after its claim at `now`. `recordingSince` starts when its claims start
208
+ * saying `recording` and clears when they stop. A claim that comes back after a gap longer than
209
+ * CLIENT_INSTANCE_GRACE_GAP_MS, still recording, gets a grace for its uploads to catch up, once
210
+ * per chunk of its own: the first grace is free, and another needs a chunk newer than the last.
211
+ * `chunkAt` is this copy's own newest chunk.
212
+ */
213
+ export function nextCopyWord(prev: CopyWord | undefined, claim: ClientInstanceClaim, now: number, chunkAt?: number): CopyWord {
214
+ const recordingReported = claim.recordingReported === true
215
+ if (!claim.recording) return { recording: false, recordingReported, seenAt: now }
216
+ if (!prev || !prev.recording || prev.recordingSince === undefined) return { recording: true, recordingReported, recordingSince: now, seenAt: now }
217
+ const cameBack = now - prev.seenAt > CLIENT_INSTANCE_GRACE_GAP_MS
218
+ const chunkSinceGrace = prev.graceAt === undefined || (chunkAt ?? 0) > prev.graceAt
219
+ const graceAt = cameBack && chunkSinceGrace ? now : prev.graceAt
220
+ return { recording: true, recordingReported, recordingSince: prev.recordingSince, ...(graceAt !== undefined ? { graceAt } : {}), seenAt: now }
221
+ }
222
+
223
+ /** The owner's word, as a CopyWord. */
224
+ function wordOf(owner: ClientInstanceOwner): CopyWord {
225
+ return {
226
+ recording: owner.recording,
227
+ recordingReported: owner.recordingReported === true,
228
+ ...(owner.recordingSince !== undefined ? { recordingSince: owner.recordingSince } : {}),
229
+ ...(owner.graceAt !== undefined ? { graceAt: owner.graceAt } : {}),
230
+ seenAt: owner.seenAt,
231
+ }
118
232
  }
119
233
 
120
234
  /** A body the route may act on, or null. Never throws. */
@@ -127,7 +241,7 @@ export function parseClientInstanceClaim(body: unknown, now = Date.now()): Clien
127
241
  // as long as the clock stayed wrong (QA, 6.50.3).
128
242
  if (o.bootAt > now + CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS) return null
129
243
  const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
130
- return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true }
244
+ return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true, recordingReported: typeof o.recording === 'boolean' }
131
245
  }
132
246
 
133
247
  function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
@@ -142,27 +256,44 @@ export function arbitrateClientInstance(
142
256
  evidence: CaptureEvidence = NO_CAPTURE,
143
257
  /** When this claimant last claimed (any verdict), or undefined: never seen. */
144
258
  claimantLastSeenAt?: number,
145
- ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
146
- const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
147
- if (!owner) return { owner: fresh, verdict: 'owner', took: true }
148
- if (owner.id === claim.id) return { owner: { ...owner, seenAt: now, recording: claim.recording }, verdict: 'owner', took: false }
259
+ /**
260
+ * 6.58.1: this claimant's word before this claim (the route keeps one per copy). Undefined:
261
+ * the owner's own word when the claimant is the owner, otherwise a copy never heard from.
262
+ */
263
+ claimantWord?: CopyWord,
264
+ ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean; word: CopyWord } {
265
+ const isOwner = owner !== null && owner.id === claim.id
266
+ const chunkAt = isOwner ? evidence.ownerChunkAt : evidence.claimantChunkAt
267
+ const word = nextCopyWord(claimantWord ?? (isOwner ? wordOf(owner) : undefined), claim, now, chunkAt)
268
+ const fresh: ClientInstanceOwner = {
269
+ ...claim,
270
+ seenAt: now,
271
+ ...(word.recordingSince !== undefined ? { recordingSince: word.recordingSince } : {}),
272
+ ...(word.graceAt !== undefined ? { graceAt: word.graceAt } : {}),
273
+ }
274
+ const decide = (o: ClientInstanceOwner, verdict: ClientInstanceVerdict, took: boolean) => ({ owner: o, verdict, took, word })
275
+ if (!owner) return decide(fresh, 'owner', true)
276
+ if (isOwner) return decide(fresh, 'owner', false)
149
277
  // 6.50.4: the yielding copy stops its recording (that is how a duplicate ends), so the
150
278
  // ring must never move in a way that stops the only recording.
151
279
  // (1) The claimant is recording and the owner is not: the claimant is the meeting.
152
280
  // Reopened offline, the Mac still names the dead copy from before; when signal returns,
153
281
  // the new copy's chunks land first and its claim must not be told to stop (QA round 2).
154
282
  // Its chunks or its own word: after an outage the uploads lag the claims (QA round 3).
155
- if ((evidence.claimant || claim.recording) && !ownerIsRecording(owner, evidence, now)) return { owner: fresh, verdict: 'owner', took: true }
283
+ // 6.58.1: the claimant's chunks count unless it says it is not recording, and its word only
284
+ // while it is backed (wordBacked), the same tests the owner meets.
285
+ const claimantRecords = (evidence.claimant && !saysNotRecording(claim)) || wordBacked(word, chunkAt, now)
286
+ if (claimantRecords && !ownerIsRecording(owner, evidence, now)) return decide(fresh, 'owner', true)
156
287
  // (2) The owner is recording: a newer copy waits, however quiet the owner's claims.
157
288
  // Reopening COS on a blank phone page mid-meeting ended the meeting on 6.50.3 (QA).
158
289
  // The ring moves on the newer copy's first claim after the meeting stops.
159
- if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
160
- if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
290
+ if (ownerIsRecording(owner, evidence, now)) return decide(owner, 'yield', false)
291
+ if (isNewer(claim, owner)) return decide(fresh, 'owner', true)
161
292
  // 6.52.3: an older boot takes a quiet ring back only after CLIENT_INSTANCE_RECLAIM_MS,
162
293
  // and only while it has itself been claiming (awake), never on the first tick after a
163
294
  // gap of its own. A dead newer copy is replaced when the user reopens COS (a newer boot
164
295
  // still takes at once); this rule is for the copy that is merely quiet.
165
296
  const claimantAwake = claimantLastSeenAt !== undefined && now - claimantLastSeenAt <= CLIENT_INSTANCE_AWAKE_MS
166
- if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return { owner: fresh, verdict: 'owner', took: true }
167
- return { owner, verdict: 'yield', took: false }
297
+ if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return decide(fresh, 'owner', true)
298
+ return decide(owner, 'yield', false)
168
299
  }
@@ -39,6 +39,10 @@ export interface QueryJobRunnerCallbacks {
39
39
  /** True only when provider ownership was durably fsynced for this active
40
40
  * generation. False means cancel/shutdown won; no prompt may be written. */
41
41
  onProviderProcess: (linkage: QueryJobProviderLinkage) => boolean | Promise<boolean>
42
+ /** 6.58.2: a link the provider names mid-run (the Claude session id). Persisted only while
43
+ * the job runs (like every link update it restamps providerOwnershipConfirmedAt); unlike
44
+ * onProviderProcess it gates nothing, and a job that already ended keeps what it had. */
45
+ onLinkage?: (linkage: QueryJobProviderLinkage) => Promise<void>
42
46
  onChunk: (text: string) => void
43
47
  onToolStatus: (text: string) => void
44
48
  onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => void
@@ -340,6 +344,9 @@ export class QueryJobCoordinator {
340
344
  })
341
345
  },
342
346
  onProviderProcess: linkage => this.providerProcessReady(active, linkage),
347
+ onLinkage: linkage => this.enqueueCallback(active, async () => {
348
+ await this.store.updateLinkage(active.jobId, linkage)
349
+ }),
343
350
  onChunk: (text) => { this.queuePartial(active, text) },
344
351
  onToolStatus: (text) => {
345
352
  if (active.request.activityToolMode === 'off') text = 'Processing...'
@@ -234,6 +234,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
234
234
  ? { ollamaRunId: metadata.runId }
235
235
  : { codexRunId: metadata.runId }),
236
236
  }),
237
+ // 6.58.2: the provider's own session id, as soon as it names it (Claude: first stream event).
238
+ onNativeSession: linkage => { void callbacks.onLinkage?.({ provider: 'claude', cliSessionId: linkage.cliSessionId }) },
237
239
  onChunk: text => { callbacks.onChunk(text) },
238
240
  onToolStatus: toolName => {
239
241
  const message = request.activityToolMode === 'off'
@@ -349,6 +351,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
349
351
  attachmentPromptBlock: resolvedAttachments.promptBlock,
350
352
  sessionLockHeld: true,
351
353
  ...(request.dispatch ? { dispatch: request.dispatch } : {}),
354
+ ...(request.sessionName ? { sessionName: request.sessionName } : {}),
352
355
  },
353
356
  )
354
357
  }
@@ -120,6 +120,45 @@ export interface QueryJobRequest {
120
120
  origin?: QueryJobOrigin
121
121
  /** Read-only dispatch constraint. Excluded from the fingerprint. */
122
122
  dispatch?: { restricted: true; tools: readonly string[] }
123
+ /**
124
+ * 6.58.2: the display name a NEW Claude session gets (`claude --name`), so a session COS
125
+ * Control starts for a Work item shows under that item's name in the Claude app instead of
126
+ * "General coding session". Sanitized by sanitizeSessionName. Excluded from the fingerprint:
127
+ * it names the session, it does not change what is asked.
128
+ */
129
+ sessionName?: string
130
+ }
131
+
132
+ /** 6.58.2: longest session name kept (the Claude sidebar shows about 40 characters). */
133
+ export const QUERY_JOB_SESSION_NAME_MAX = 100
134
+
135
+ /**
136
+ * 6.58.2: a display name, or undefined. Control characters, zero-width spaces and direction
137
+ * marks and overrides become spaces (the joiners U+200C and U+200D stay: scripts and emoji need
138
+ * them), runs of whitespace collapse, and a name longer than QUERY_JOB_SESSION_NAME_MAX
139
+ * characters (grapheme clusters, as Swift counts them) is cut there, back to its last space when
140
+ * that space is in the second half. The cut never splits a character. Never throws: a bad name
141
+ * is no name. COS Control applies the same rules before it sends the name.
142
+ */
143
+ export function sanitizeSessionName(raw: unknown): string | undefined {
144
+ if (typeof raw !== 'string') return undefined
145
+ // eslint-disable-next-line no-control-regex
146
+ const flat = raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' ').replace(/\s+/g, ' ').trim()
147
+ if (!flat) return undefined
148
+ const chars = graphemes(flat)
149
+ if (chars.length <= QUERY_JOB_SESSION_NAME_MAX) return flat
150
+ const cut = chars.slice(0, QUERY_JOB_SESSION_NAME_MAX)
151
+ const space = cut.lastIndexOf(' ')
152
+ return (space >= QUERY_JOB_SESSION_NAME_MAX / 2 ? cut.slice(0, space) : cut).join('').trim()
153
+ }
154
+
155
+ const graphemeSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
156
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
157
+ : null
158
+
159
+ /** Grapheme clusters when the runtime can segment them, else code points (never half a pair). */
160
+ function graphemes(text: string): string[] {
161
+ return graphemeSegmenter ? Array.from(graphemeSegmenter.segment(text), part => part.segment) : Array.from(text)
123
162
  }
124
163
 
125
164
  export const DISPATCH_ALLOWED_TOOLS = ['Read', 'Grep', 'Glob'] as const
@@ -325,6 +364,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
325
364
  const messageEra = optionalString(input.messageEra, 'message_era', 80)
326
365
  const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
327
366
  const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
367
+ const sessionName = sanitizeSessionName(input.sessionName)
328
368
  const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
329
369
  if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
330
370
  throw new QueryJobValidationError('invalid_global_msg_num')
@@ -395,6 +435,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
395
435
  ...(handoffCode ? { handoffCode } : {}),
396
436
  ...(input.handoffLatest === true ? { handoffLatest: true } : {}),
397
437
  ...(clientQueueItemId ? { clientQueueItemId } : {}),
438
+ ...(sessionName ? { sessionName } : {}),
398
439
  attachmentIds,
399
440
  attachmentRefs,
400
441
  activityToolMode,
@@ -464,7 +505,7 @@ export const FINGERPRINT_KEYS = [
464
505
  /** The request keys deliberately OUTSIDE the identity. Every key of
465
506
  * `QueryJobRequest` must appear in exactly one of the two lists: adding a key
466
507
  * without naming it here or above fails to compile, so the author picks a side. */
467
- export const FINGERPRINT_EXCLUDED = ['origin', 'dispatch'] as const satisfies readonly (keyof QueryJobRequest)[]
508
+ export const FINGERPRINT_EXCLUDED = ['origin', 'dispatch', 'sessionName'] as const satisfies readonly (keyof QueryJobRequest)[]
468
509
  type FingerprintUncovered = Exclude<keyof QueryJobRequest, typeof FINGERPRINT_KEYS[number] | typeof FINGERPRINT_EXCLUDED[number]>
469
510
  export const FINGERPRINT_KEYS_COVER_REQUEST: FingerprintUncovered extends never ? true : never = true
470
511
 
@@ -21,8 +21,8 @@ import {
21
21
  parseClientInstanceClaim,
22
22
  parseInstanceTag,
23
23
  ClientChunkLedger,
24
- NO_CAPTURE,
25
24
  type ClientInstanceOwner,
25
+ type CopyWord,
26
26
  } from '../lib/client-instance-claim.js'
27
27
 
28
28
  /** Owners kept at once; the least recently seen device is dropped past this. */
@@ -58,6 +58,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
58
58
  lastSeen.delete(id); lastSeen.set(id, at)
59
59
  while (lastSeen.size > CLIENT_INSTANCE_MAX_SEEN) lastSeen.delete(lastSeen.keys().next().value as string)
60
60
  }
61
+ // 6.58.1: what each copy has said about recording, kept across changes of owner (CopyWord).
62
+ // Bounded like lastSeen; a check updates it too (the copy still said it).
63
+ const words = new Map<string, CopyWord>()
64
+ const noteWord = (id: string, word: CopyWord) => {
65
+ words.delete(id); words.set(id, word)
66
+ while (words.size > CLIENT_INSTANCE_MAX_SEEN) words.delete(words.keys().next().value as string)
67
+ }
61
68
  router.use((req, _res, next) => {
62
69
  if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
63
70
  next()
@@ -68,11 +75,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
68
75
  if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
69
76
  const device = deviceKey(req.socket?.remoteAddress ?? req.ip)
70
77
  const previous = owners.get(device) ?? null
71
- const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
78
+ // 6.58.1: evidence even with no owner, so a first claim still knows its own newest chunk.
79
+ const evidence = chunks.evidence(device, previous?.id ?? '', claim.id, at)
72
80
  const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
73
81
  const claimantLastSeenAt = lastSeen.get(claim.id)
74
82
  noteSeen(claim.id, at)
75
- const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt)
83
+ const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt, words.get(claim.id))
84
+ noteWord(claim.id, result.word)
76
85
  if (req.body?.check === true) {
77
86
  const shown = previous ?? result.owner
78
87
  return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })