@gotcos/glasses-server 6.36.14 → 6.36.18

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,4 +1,47 @@
1
- ## Unreleased
1
+ ## 6.36.18
2
+ - **Meetings list now carries voice-assignment tags.** Each row includes
3
+ `voiceReview` from the sidecar head (`speakers[]`) plus whether a human
4
+ correction landed in the ledger. Control paints NEW / N to name / REVIEWED
5
+ without opening each meeting. Still a 4 KB head read — not a chunk parse.
6
+
7
+ ## 6.36.17
8
+ - **Naming a new person from a wrong existing label now creates their voice profile.**
9
+ Enrolment after `POST /relabel` only fired when `from` was a placeholder (`Ext`,
10
+ `Unknown`, `Unidentified N`). The live path in Speakers review is the other one:
11
+ the identifier weakly matches someone already enrolled, and the reviewer says
12
+ **This is someone else → Use "Milo LeBaron"**. Measured 2026-08-20 on
13
+ `meeting_1787234635703_t4iz74`: Nick Gurney → Milo LeBaron, 19 chunks, ledger
14
+ `applied`, 78 profiles, no Milo. Backfill used the same guard, so the meeting
15
+ could not be enrolled after the fact either (`eligible: 0`, `skippedNamedSource: 1`).
16
+ - **Enrol by target, not by source.** `enrolNamedVoice` still skips a placeholder
17
+ `to` and an empty `changed` list. A real `to` enrols those chunks — creates the
18
+ profile when it does not exist, appends when it does — through the same raw-index
19
+ map, coherence gate, 20-sample cap, and `correction:<sessionId>` tag. Global fold
20
+ of two identities remains `merge-profiles`. Per-meeting chunk assignment is not
21
+ that. Mutating the old `from`-placeholder guard back in fails the new Nick → Milo
22
+ test.
23
+
24
+ ## 6.36.16
25
+ - **Cursor Agent Continue.** Continue now resumes a Cursor Agent CLI thread with
26
+ `agent --resume <id> --workspace <spawn spelling>` in ask-mode. Bindable, not
27
+ forkable: Fork on Cursor still has no spawn path. Occupancy treats a resolved
28
+ `~/.cursor/chats/<hash>/<id>/` session (`hasConversation: true`) as attachable
29
+ with no invented process owner. `--workspace` uses the jsonl folder slug that
30
+ already exists — never `realpath` of `meta.json.cwd`, which creates a second
31
+ transcript folder. Queue is refused for Cursor. The 6.36.15 jsonl-mtime write
32
+ hint is unpublished on the LIST; detail still uses the jsonl mtime the handler
33
+ already stat'ed as a display-only working signal, never a write gate.
34
+
35
+ ## 6.36.15 (unpublished)
36
+ - **Cursor sessions can show as working.** Occupancy only scanned Claude
37
+ (registry) and Codex (writer lock). Cursor has neither, so every Cursor row
38
+ stamped `running: false` even while the jsonl was being written — the lens
39
+ stayed on the digest and the list never showed a live Cursor turn. The list
40
+ already paid for Cursor `modified` as the jsonl mtime; a write inside the same
41
+ 30s window Claude/Codex use for `running_active` now synthesizes a display
42
+ hint (`running` + `running_active`, `running_foreign` still false). Detail
43
+ uses the file it already stat'ed. Still a hint, never a write gate. Withheld:
44
+ Continue on Cursor is 6.36.16, and this mtime hint is not in that train.
2
45
 
3
46
  ## 6.36.14
4
47
  - **The session LIST now reports what its caps hid.** The 7-day age gate, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.14",
3
+ "version": "6.36.18",
4
4
  "description": "COS Glasses — 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
@@ -368,7 +368,7 @@ const occupancyProbes = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, thr
368
368
  * before a single prompt byte is written, which is the veto the route wants.
369
369
  */
370
370
  const deliverAttachedTurnForRoute = async (request: {
371
- provider: 'claude' | 'codex'
371
+ provider: 'claude' | 'codex' | 'cursor'
372
372
  nativeThreadId: string
373
373
  prompt: string
374
374
  onSpawn: (pid: number) => boolean
@@ -39,10 +39,14 @@ import type { AgentProvider } from './agent-session-store.js'
39
39
 
40
40
  export type BindingState = 'staging' | 'active' | 'detaching' | 'detached'
41
41
 
42
- /** Providers that can carry a binding. Cursor is Fork-only (plan 2.5). */
43
- export const BINDABLE_PROVIDERS = ['claude', 'codex'] as const
42
+ /** Providers that can carry a Continue binding. Cursor is bindable, not forkable. */
43
+ export const BINDABLE_PROVIDERS = ['claude', 'codex', 'cursor'] as const
44
44
  export type BindableProvider = (typeof BINDABLE_PROVIDERS)[number]
45
45
 
46
+ /** Providers that can fork. Cursor Agent has no `--fork-session` equivalent. */
47
+ export const FORKABLE_PROVIDERS = ['claude', 'codex'] as const
48
+ export type ForkableProvider = (typeof FORKABLE_PROVIDERS)[number]
49
+
46
50
  export interface NativeBinding {
47
51
  bindingId: string
48
52
  cosSessionId: string
@@ -95,6 +99,10 @@ export function isBindableProvider(value: unknown): value is BindableProvider {
95
99
  return typeof value === 'string' && (BINDABLE_PROVIDERS as readonly string[]).includes(value)
96
100
  }
97
101
 
102
+ export function isForkableProvider(value: unknown): value is ForkableProvider {
103
+ return typeof value === 'string' && (FORKABLE_PROVIDERS as readonly string[]).includes(value)
104
+ }
105
+
98
106
  /**
99
107
  * Injective composite key: `<len>:<provider>:<len>:<threadId>`.
100
108
  *
@@ -58,6 +58,7 @@ export interface AgentSessionRoots {
58
58
  claudeCodeSessions: string
59
59
  codexSessions: string
60
60
  cursorProjects: string
61
+ cursorChats: string
61
62
  cursorComposerDb: string
62
63
  cursorWorkspaceStorage: string
63
64
  }
@@ -69,6 +70,7 @@ export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME ||
69
70
  claudeCodeSessions: join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions'),
70
71
  codexSessions: join(home, '.codex', 'sessions'),
71
72
  cursorProjects: join(home, '.cursor', 'projects'),
73
+ cursorChats: join(home, '.cursor', 'chats'),
72
74
  cursorComposerDb: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
73
75
  cursorWorkspaceStorage: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'),
74
76
  }
@@ -94,8 +94,9 @@ import { isBindableProvider, type BindableProvider } from './agent-session-bindi
94
94
  import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
95
95
  import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
96
96
  import { getCodexTrustMode } from './codex-run-ledger.js'
97
+ import { CURSOR_SLOT_MODEL_IDS } from './cursor-model-catalog.js'
97
98
 
98
- /** Providers with a certified attached path. Cursor is Fork-only (plan 2.5). */
99
+ /** Providers with a certified attached path. Cursor is bindable, not forkable. */
99
100
  export type AttachedProvider = BindableProvider
100
101
 
101
102
  /**
@@ -486,16 +487,46 @@ export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string): str
486
487
  ]
487
488
  }
488
489
 
490
+ /**
491
+ * Cursor Agent CLI: ask-mode resume into an existing thread.
492
+ *
493
+ * `--force` is the agent-mode bypass and is banned here. Workspace is the
494
+ * spawn spelling from `spawnWorkspace`, never realpath of `meta.json.cwd`.
495
+ */
496
+ export function buildCursorAttachedArgs(nativeThreadId: string, cwd: string): string[] {
497
+ return [
498
+ '-p',
499
+ '--mode', 'ask',
500
+ '--model', CURSOR_SLOT_MODEL_IDS['cursor-composer'],
501
+ '--output-format', 'stream-json',
502
+ '--stream-partial-output',
503
+ '--trust',
504
+ '--workspace', cwd,
505
+ '--resume', nativeThreadId,
506
+ ]
507
+ }
508
+
509
+ const PATH_VALUED_FLAGS = new Set(['--workspace', '-C', '--cd', '--add-dir'])
510
+ const BARE_BANNED_PERMISSION_ARGS = new Set(
511
+ BANNED_PERMISSION_ARGS.filter(flag => !flag.startsWith('-')),
512
+ )
513
+
489
514
  /**
490
515
  * Is this argv free of every flag plan 4.7 bans?
491
516
  *
492
- * Substring, not equality: a banned token can arrive attached to its value
493
- * (`--permission-mode=bypassPermissions`, `--sandbox danger-full-access`), and an
494
- * equality check would wave those through while looking correct.
517
+ * Flag-position tokens (start with `-`) are substring-matched so
518
+ * `--permission-mode=bypassPermissions` still hits. Bare tokens
519
+ * (`danger-full-access`) match only as their own argv slot. Values of
520
+ * `--workspace` / `-C` are skipped: a cwd containing `--force` is a path,
521
+ * not a permission flag.
495
522
  */
496
523
  export function findBannedPermissionArg(args: readonly string[]): string | null {
497
- for (const arg of args) {
498
- const value = String(arg)
524
+ for (let i = 0; i < args.length; i++) {
525
+ const value = String(args[i])
526
+ const prev = i > 0 ? String(args[i - 1]) : ''
527
+ if (PATH_VALUED_FLAGS.has(prev)) continue
528
+ if (BARE_BANNED_PERMISSION_ARGS.has(value)) return value
529
+ if (!value.startsWith('-')) continue
499
530
  for (const banned of BANNED_PERMISSION_ARGS) {
500
531
  if (value.includes(banned)) return banned
501
532
  }
@@ -520,7 +551,9 @@ export function findBannedPermissionArg(args: readonly string[]): string | null
520
551
  function buildArgs(provider: AttachedProvider, nativeThreadId: string, cwd: string): string[] {
521
552
  const args = provider === 'claude'
522
553
  ? buildClaudeAttachedArgs(nativeThreadId)
523
- : buildCodexAttachedArgs(nativeThreadId, cwd)
554
+ : provider === 'codex'
555
+ ? buildCodexAttachedArgs(nativeThreadId, cwd)
556
+ : buildCursorAttachedArgs(nativeThreadId, cwd)
524
557
  const banned = findBannedPermissionArg(args)
525
558
  if (banned !== null) {
526
559
  // The flag name only. Never the argv, which carries the thread id and cwd.
@@ -30,8 +30,10 @@ import { createHash } from 'node:crypto'
30
30
  import { closeSync, constants as fsConstants, openSync, readSync, statSync } from 'node:fs'
31
31
  import { isAbsolute } from 'node:path'
32
32
  import { realNativeHeadDeps, transcriptPathFor, type NativeHeadDeps } from './native-head.js'
33
+ import { spawnWorkspace } from './cursor-agent-store.js'
34
+ import { agentSessionRoots } from './agent-session-store.js'
33
35
 
34
- export type AttachedWorkspaceProvider = 'claude' | 'codex'
36
+ export type AttachedWorkspaceProvider = 'claude' | 'codex' | 'cursor'
35
37
 
36
38
  export interface ResolvedWorkspace {
37
39
  /**
@@ -52,6 +54,11 @@ export interface AttachedWorkspaceDeps {
52
54
  readHead: (path: string, maxBytes: number) => string | null
53
55
  /** Does this directory exist right now? MUST THROW on "cannot tell". */
54
56
  dirExists: (path: string) => boolean
57
+ /**
58
+ * Spawn-spelling cwd for a Cursor Agent session. Never the realpath of
59
+ * `meta.json.cwd` — that creates a second jsonl folder (canary H).
60
+ */
61
+ cursorSpawnWorkspace?: (threadId: string) => string | null
55
62
  }
56
63
 
57
64
  /** Enough to reach a Claude message row or the Codex session meta row. */
@@ -113,6 +120,29 @@ export function resolveAttachedWorkspace(
113
120
  threadId: string,
114
121
  deps: AttachedWorkspaceDeps,
115
122
  ): ResolvedWorkspace | null {
123
+ if (provider === 'cursor') {
124
+ let cwd: string | null
125
+ let path: string | null
126
+ try {
127
+ cwd = deps.cursorSpawnWorkspace?.(threadId) ?? null
128
+ path = deps.transcriptPath('cursor', threadId)
129
+ } catch {
130
+ return null
131
+ }
132
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !path) return null
133
+ let exists: boolean
134
+ try {
135
+ exists = deps.dirExists(cwd)
136
+ } catch {
137
+ return null
138
+ }
139
+ if (!exists) return null
140
+ return {
141
+ path: cwd,
142
+ workspaceFingerprint: fingerprint(cwd),
143
+ sourceFingerprint: fingerprint(path),
144
+ }
145
+ }
116
146
  if (provider !== 'claude' && provider !== 'codex') return null
117
147
  let path: string | null
118
148
  let text: string | null
@@ -163,8 +193,13 @@ export function resolveAttachedWorkspace(
163
193
  export function realAttachedWorkspaceDeps(
164
194
  headDeps: NativeHeadDeps = realNativeHeadDeps(),
165
195
  ): AttachedWorkspaceDeps {
196
+ const roots = agentSessionRoots()
166
197
  return {
167
198
  transcriptPath: (provider, threadId) => transcriptPathFor(provider, threadId, headDeps),
199
+ cursorSpawnWorkspace: threadId => spawnWorkspace(threadId, {
200
+ cursorChatsDir: roots.cursorChats,
201
+ cursorProjectsDir: roots.cursorProjects,
202
+ }),
168
203
 
169
204
  readHead: (path, maxBytes) => {
170
205
  let fd: number | null = null
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from 'node:path'
18
18
  import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
19
19
  import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
20
  import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
+ import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
21
22
 
22
23
  /**
23
24
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -151,30 +152,35 @@ const SIDECAR_HEAD_BYTES = 4096
151
152
  * them whole would make listing cost scale with total transcript size — and this
152
153
  * lister already reads every markdown file it finds.
153
154
  */
154
- export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
155
+ export function sidecarListHints(monthDir: string, meetingFilename: string): {
156
+ sessionId?: string
157
+ speakers: string[]
158
+ } {
155
159
  const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
156
- if (sidecarName === meetingFilename) return undefined
160
+ if (sidecarName === meetingFilename) return { speakers: [] }
157
161
  const path = join(monthDir, sidecarName)
158
162
  let fd: number | null = null
159
163
  try {
160
164
  const linkStat = lstatSync(path)
161
- if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
165
+ if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return { speakers: [] }
162
166
  const real = realpathSync(path)
163
- if (dirname(real) !== realpathSync(monthDir)) return undefined
167
+ if (dirname(real) !== realpathSync(monthDir)) return { speakers: [] }
164
168
  const stat = statSync(real)
165
169
  fd = openSync(path, 'r')
166
170
  const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
167
171
  const read = readSync(fd, buffer, 0, buffer.length, 0)
168
- const match = buffer.subarray(0, read).toString('utf8')
169
- .match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
170
- return match ? match[1] : undefined
172
+ return parseSidecarListHead(buffer.subarray(0, read).toString('utf8'))
171
173
  } catch {
172
- return undefined
174
+ return { speakers: [] }
173
175
  } finally {
174
176
  if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
175
177
  }
176
178
  }
177
179
 
180
+ export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
181
+ return sidecarListHints(monthDir, meetingFilename).sessionId
182
+ }
183
+
178
184
  function envPath(name: string): string | null {
179
185
  const raw = process.env[name]?.trim()
180
186
  if (!raw) return null
@@ -563,8 +569,11 @@ export function listCosOperationsMeetings(options: {
563
569
  meta.recordId = `ops:${domain}:${month}:${file}`
564
570
  meta.mutable = true
565
571
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
566
- const sessionId = sidecarSessionId(monthDir, file)
567
- if (sessionId) meta.sessionId = sessionId
572
+ const hints = sidecarListHints(monthDir, file)
573
+ if (hints.sessionId) meta.sessionId = hints.sessionId
574
+ if (hints.speakers.length > 0) {
575
+ meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
576
+ }
568
577
  if (options.day && meta.date !== options.day) continue
569
578
  allMeetings.push(meta)
570
579
  } catch { /* skip unreadable files */ }
@@ -0,0 +1,189 @@
1
+ // Cursor Agent CLI store: `~/.cursor/chats/<hash>/<uuid>/` plus the jsonl
2
+ // project-folder slug Agent keys off `--workspace`.
3
+ //
4
+ // Continue attaches only when this helper resolves. IDE-only composers have a
5
+ // jsonl and no chats dir — Gate 0. Empty `hasConversation: false` stubs are
6
+ // not sessions. Canary H: passing realpath `meta.json.cwd` (`/private/tmp/...`)
7
+ // as `--workspace` creates a SECOND jsonl folder. Spawn must use the spelling
8
+ // whose slug already exists.
9
+
10
+ import { existsSync, lstatSync, readFileSync, realpathSync, readdirSync } from 'node:fs'
11
+ import { isAbsolute, join } from 'node:path'
12
+ import { isValidNativeThreadId } from './native-thread-id.js'
13
+
14
+ export const MAX_CURSOR_CHAT_HASH_DIRS = 2048
15
+ export const MAX_CURSOR_CHAT_DIRS = 4096
16
+ export const MAX_CURSOR_PROJECT_DIRS = 2048
17
+
18
+ export interface CursorAgentSession {
19
+ dir: string
20
+ cwd: string
21
+ hasConversation: boolean
22
+ createdAtMs: number | null
23
+ }
24
+
25
+ export interface CursorAgentStoreDirs {
26
+ cursorChatsDir: string
27
+ cursorProjectsDir: string
28
+ }
29
+
30
+ export function encodeCursorWorkspaceSlug(workspace: string): string {
31
+ const trimmed = workspace.trim()
32
+ const withoutRoot = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed
33
+ // Cursor's project-folder alphabet is [A-Za-z0-9-]. Every other run of
34
+ // characters — underscores, dots, spaces, slashes — collapses to one hyphen.
35
+ // Matching only `/` and whitespace left `_` and `.` in the slug, so
36
+ // spawnWorkspace could never find the real folder (wk30_2026, .module, …).
37
+ return withoutRoot.replace(/[^A-Za-z0-9]+/g, '-')
38
+ }
39
+
40
+ export function cursorWorkspaceSpellings(cwd: string): string[] {
41
+ const trimmed = cwd.trim()
42
+ if (!trimmed) return []
43
+ const out = [trimmed]
44
+ if (trimmed.startsWith('/private/tmp/')) {
45
+ out.push(`/tmp/${trimmed.slice('/private/tmp/'.length)}`)
46
+ } else if (trimmed.startsWith('/tmp/')) {
47
+ out.push(`/private/tmp/${trimmed.slice('/tmp/'.length)}`)
48
+ }
49
+ return out
50
+ }
51
+
52
+ function realpathOrNull(path: string): string | null {
53
+ try {
54
+ return realpathSync(path)
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ function dirExists(path: string): boolean {
61
+ try {
62
+ return lstatSync(path).isDirectory()
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ function readDirNames(path: string): string[] {
69
+ return readdirSync(path, { withFileTypes: true }).map(entry => entry.name)
70
+ }
71
+
72
+ function skipProjectFolder(folder: string): boolean {
73
+ return folder.includes('var-folders') || folder.includes('private-var') || folder === 'empty-window'
74
+ }
75
+
76
+ export function resolveCursorAgentSession(
77
+ threadId: string,
78
+ chatsDir: string,
79
+ ): CursorAgentSession | null {
80
+ if (!isValidNativeThreadId(threadId)) return null
81
+ if (!chatsDir || !dirExists(chatsDir)) return null
82
+
83
+ let hashes: string[]
84
+ try {
85
+ hashes = readDirNames(chatsDir)
86
+ } catch {
87
+ return null
88
+ }
89
+ if (hashes.length > MAX_CURSOR_CHAT_HASH_DIRS) return null
90
+
91
+ const matches: string[] = []
92
+ let scanned = 0
93
+ for (const hash of hashes) {
94
+ const hashDir = join(chatsDir, hash)
95
+ if (!dirExists(hashDir)) continue
96
+ const candidate = join(hashDir, threadId)
97
+ if (++scanned > MAX_CURSOR_CHAT_DIRS) return null
98
+ if (!dirExists(candidate)) continue
99
+ if (!existsSync(join(candidate, 'meta.json'))) continue
100
+ matches.push(candidate)
101
+ if (matches.length > 1) return null
102
+ }
103
+ if (matches.length !== 1) return null
104
+
105
+ const dir = matches[0]!
106
+ let raw: string
107
+ try {
108
+ raw = readFileSync(join(dir, 'meta.json'), 'utf8')
109
+ } catch {
110
+ return null
111
+ }
112
+ let meta: Record<string, unknown>
113
+ try {
114
+ const parsed = JSON.parse(raw)
115
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
116
+ meta = parsed as Record<string, unknown>
117
+ } catch {
118
+ return null
119
+ }
120
+ if (meta.hasConversation !== true) return null
121
+ const cwd = typeof meta.cwd === 'string' ? meta.cwd.trim() : ''
122
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) return null
123
+ const resolved = realpathOrNull(cwd)
124
+ if (!resolved || !dirExists(resolved)) return null
125
+ const createdAtMs = typeof meta.createdAtMs === 'number' && Number.isFinite(meta.createdAtMs)
126
+ ? meta.createdAtMs
127
+ : null
128
+ return { dir, cwd, hasConversation: true, createdAtMs }
129
+ }
130
+
131
+ export function transcriptFoldersFor(
132
+ threadId: string,
133
+ projectsDir: string,
134
+ ): string[] {
135
+ if (!isValidNativeThreadId(threadId)) return []
136
+ if (!projectsDir || !dirExists(projectsDir)) return []
137
+ let folders: string[]
138
+ try {
139
+ folders = readDirNames(projectsDir)
140
+ } catch {
141
+ return []
142
+ }
143
+ if (folders.length > MAX_CURSOR_PROJECT_DIRS) return []
144
+
145
+ const found: string[] = []
146
+ for (const folder of folders) {
147
+ if (skipProjectFolder(folder)) continue
148
+ const file = join(projectsDir, folder, 'agent-transcripts', threadId, `${threadId}.jsonl`)
149
+ try {
150
+ if (!lstatSync(file).isFile()) continue
151
+ } catch {
152
+ continue
153
+ }
154
+ found.push(folder)
155
+ }
156
+ return found
157
+ }
158
+
159
+ export function spawnWorkspace(
160
+ threadId: string,
161
+ dirs: CursorAgentStoreDirs,
162
+ ): string | null {
163
+ const session = resolveCursorAgentSession(threadId, dirs.cursorChatsDir)
164
+ if (!session) return null
165
+ const folders = transcriptFoldersFor(threadId, dirs.cursorProjectsDir)
166
+ if (folders.length !== 1) return null
167
+ const expected = folders[0]!
168
+ const sessionReal = realpathOrNull(session.cwd)
169
+ if (!sessionReal) return null
170
+ for (const candidate of cursorWorkspaceSpellings(session.cwd)) {
171
+ if (encodeCursorWorkspaceSlug(candidate) !== expected) continue
172
+ const candidateReal = realpathOrNull(candidate)
173
+ if (!candidateReal || candidateReal !== sessionReal) continue
174
+ if (!dirExists(candidate) && !dirExists(candidateReal)) continue
175
+ const still = transcriptFoldersFor(threadId, dirs.cursorProjectsDir)
176
+ if (still.length !== 1 || still[0] !== expected) return null
177
+ return candidate
178
+ }
179
+ return null
180
+ }
181
+
182
+ export function cursorTranscriptPath(
183
+ threadId: string,
184
+ projectsDir: string,
185
+ ): string | null {
186
+ const folders = transcriptFoldersFor(threadId, projectsDir)
187
+ if (folders.length !== 1) return null
188
+ return join(projectsDir, folders[0]!, 'agent-transcripts', threadId, `${threadId}.jsonl`)
189
+ }
@@ -5,8 +5,8 @@
5
5
 
6
6
  import { spawn } from 'node:child_process'
7
7
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
- import { homedir } from 'node:os'
9
8
  import { dirname, resolve } from 'node:path'
9
+ import { resolveProviderBinary } from './provider-binary.js'
10
10
  import {
11
11
  CURSOR_COMPOSER_MODEL,
12
12
  CURSOR_GROK_MODEL,
@@ -93,20 +93,10 @@ function catalogCachePath(): string {
93
93
  )
94
94
  }
95
95
 
96
- /** Resolve the Cursor `agent` binary: PATH first, then ~/.local/bin/agent. */
96
+ /** Resolve the Cursor `agent` binary via the shared provider table. */
97
97
  export function resolveAgentBinary(): string | undefined {
98
- const configured = process.env.COS_CURSOR_AGENT_BIN?.trim()
99
- if (configured && existsSync(configured)) return configured
100
-
101
- const pathEntries = (process.env.PATH ?? '').split(':').filter(Boolean)
102
- for (const entry of pathEntries) {
103
- const candidate = resolve(entry, 'agent')
104
- if (existsSync(candidate)) return candidate
105
- }
106
-
107
- const homeLocal = resolve(homedir(), '.local', 'bin', 'agent')
108
- if (existsSync(homeLocal)) return homeLocal
109
- return undefined
98
+ const resolved = resolveProviderBinary('cursor')
99
+ return resolved.ok ? resolved.path : undefined
110
100
  }
111
101
 
112
102
  /** Parse `agent models` text lines shaped like `id - Display Name`. */
@@ -65,7 +65,7 @@ import { isAbsolute } from 'node:path'
65
65
  import { spawn as nodeSpawn } from 'node:child_process'
66
66
 
67
67
  import { isValidNativeThreadId } from './native-thread-id.js'
68
- import { isBindableProvider } from './agent-session-binding-store.js'
68
+ import { isForkableProvider, type ForkableProvider } from './agent-session-binding-store.js'
69
69
  import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
70
70
  import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
71
71
  import {
@@ -82,20 +82,19 @@ import {
82
82
  isAttachedPermissionPolicy,
83
83
  resolveProviderBinary,
84
84
  type AttachedChildProcess,
85
- type AttachedProvider,
86
85
  type AttachedSpawnRequest,
87
86
  type AttachedStderrClass,
88
87
  type BinaryResolution,
89
88
  } from './attached-provider-adapter.js'
90
89
 
91
90
  /**
92
- * Providers that can be forked. Identical to the attached set on purpose.
91
+ * Providers that can be forked. A strict subset of the attached set.
93
92
  *
94
- * Re-exported from the adapter rather than redeclared: a provider that gains an
95
- * attached path and not a fork path (or the reverse) would leave one of the two
96
- * halves of this feature silently unreachable.
93
+ * Cursor can Continue (ask-mode resume) and cannot Fork: Agent CLI has no
94
+ * `--fork-session` equivalent, and advertising Fork on Cursor would 404 the
95
+ * only remaining write path for that row if Continue were also hidden.
97
96
  */
98
- export type ForkProvider = AttachedProvider
97
+ export type ForkProvider = ForkableProvider
99
98
 
100
99
  /** Same one-member policy as the attached path. An omitted policy is refused. */
101
100
  export type ForkPermissionPolicy = 'read_only'
@@ -524,7 +523,7 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
524
523
  const duration = () => readDuration(deps, startedAt)
525
524
 
526
525
  // --- 1. Validate ------------------------------------------------------------
527
- if (!isBindableProvider(request.provider)) {
526
+ if (!isForkableProvider(request.provider)) {
528
527
  return fail('invalid_provider', 'none', { durationMs: duration() })
529
528
  }
530
529
  const provider: ForkProvider = request.provider
@@ -86,8 +86,9 @@ export interface EnrolmentReport {
86
86
  /** Samples the voice store actually accepted. */
87
87
  enrolled: number
88
88
  /** Candidate embeddings found for the relabelled chunks, before any gating.
89
- * Zero here means this correction was never an enrolment (a real name being
90
- * corrected to another real name), not that enrolment failed. */
89
+ * Zero with `skipped: null` means this correction was never an enrolment
90
+ * (empty `changed`, or the target is still a placeholder), not that
91
+ * enrolment failed. */
91
92
  attempted: number
92
93
  /** True ONLY when a profile did not exist for this name and now does. An
93
94
  * existing name is APPENDED to, and must never be reported as created. */
@@ -179,12 +180,17 @@ export interface EnrolNamedVoiceInput {
179
180
  /**
180
181
  * Enrol the voice a human just named.
181
182
  *
182
- * SCOPED to placeholder -> real name. Correcting one real name to another is left
183
- * alone deliberately: moving a voice between existing people is `merge-profiles`,
184
- * which is explicit and confirmation-gated, and a sweep of this store put two
185
- * DISTINCT people at 0.85 similarity, so doing it implicitly would poison both.
183
+ * SCOPED to a real target name. The reviewer is assigning THESE chunks to `to`.
184
+ * That is a first training run when `to` has no profile (the live case: Nick
185
+ * Gurney Milo LeBaron, 2026-08-20 19 chunks relabelled, 78 profiles, no
186
+ * Milo) and an append when the profile already exists. Global fold of two
187
+ * identities remains `merge-profiles`. Per-meeting chunk assignment is not
188
+ * that, and gating on `from` being a placeholder made a wrong existing label
189
+ * (the identifier's 0.55 Nick match) create no profile at all.
186
190
  *
187
- * An EXISTING name is appended to without a prompt, and reports `created: false`.
191
+ * Still skipped: a placeholder `to` (Ext Unidentified 2), and an empty
192
+ * `changed` list. An EXISTING name is appended to without a prompt, and
193
+ * reports `created: false`.
188
194
  *
189
195
  * Samples are stamped `correction:<sessionId>`, not a bare source string. The
190
196
  * prefix is load-bearing in four places: `isSampleFromSession` accepts only
@@ -198,11 +204,14 @@ export interface EnrolNamedVoiceInput {
198
204
  * on disk; a voice store that refuses must not undo what the user asked for.
199
205
  */
200
206
  export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
201
- const { sessionId, from, to, changed, sidecar } = input
207
+ const { sessionId, to, changed, sidecar } = input
202
208
 
203
209
  // Not an enrolment at all. `attempted: 0` with `skipped: null` is how the
204
210
  // caller tells this apart from an enrolment that found no candidates.
205
- if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return IDLE
211
+ // `from` is deliberately NOT gated: a wrong existing label is how a new
212
+ // person first appears in review (Nick Gurney → Milo LeBaron). Gating on
213
+ // placeholder-only `from` labelled the meeting and taught the store nothing.
214
+ if (isPlaceholderLabel(to) || changed.length === 0) return IDLE
206
215
 
207
216
  if (!chunkEmbeddingsEnabled()) return { ...IDLE, skipped: 'disabled' }
208
217
  // No extractor/manager means every enrollEmbedding would return success:false.
@@ -94,6 +94,13 @@ export interface MeetingMeta {
94
94
  mutable?: boolean
95
95
  /** Present only when the server can state a truthful local record. */
96
96
  canonicalRecord?: string
97
+ /** Additive. Unique sidecar speakers + whether a human correction landed. */
98
+ voiceReview?: {
99
+ voices: number
100
+ unattributedVoices: number
101
+ namedVoices: number
102
+ humanTouched: boolean
103
+ }
97
104
  }
98
105
 
99
106
  export interface MeetingActionItem {
@@ -0,0 +1,53 @@
1
+ // Cheap voice-assignment stats for the meetings LIST.
2
+ //
3
+ // The Speakers panel's "Meetings to review" row cannot open every sidecar just
4
+ // to paint a tag. The unique `speakers` array sits at the top of the sidecar,
5
+ // so a 4 KB head read already used for `sessionId` is enough. Segment counts
6
+ // stay on the per-meeting review route.
7
+
8
+ import { isUnattributed } from './meeting-speaker-review.js'
9
+ import { readCorrections } from './meeting-corrections.js'
10
+
11
+ export interface MeetingVoiceReview {
12
+ voices: number
13
+ unattributedVoices: number
14
+ namedVoices: number
15
+ humanTouched: boolean
16
+ }
17
+
18
+ export function parseSidecarListHead(head: string): {
19
+ sessionId?: string
20
+ speakers: string[]
21
+ } {
22
+ const sessionId = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)?.[1]
23
+ const block = head.match(/"speakers"\s*:\s*\[([^\]]*)\]/)
24
+ const speakers = block
25
+ ? [...block[1].matchAll(/"((?:\\.|[^"\\])*)"/g)].map(match => match[1].replace(/\\"/g, '"'))
26
+ : []
27
+ return { sessionId, speakers }
28
+ }
29
+
30
+ export function meetingVoiceReview(
31
+ speakers: string[],
32
+ sessionId?: string,
33
+ ): MeetingVoiceReview {
34
+ const labels = [...new Set(speakers.map(name => name.trim()).filter(Boolean))]
35
+ const unattributedVoices = labels.filter(isUnattributed).length
36
+ return {
37
+ voices: labels.length,
38
+ unattributedVoices,
39
+ namedVoices: Math.max(0, labels.length - unattributedVoices),
40
+ humanTouched: sessionWasHumanTouched(sessionId),
41
+ }
42
+ }
43
+
44
+ export function sessionWasHumanTouched(sessionId?: string): boolean {
45
+ if (!sessionId) return false
46
+ try {
47
+ return readCorrections(sessionId).rows.some(
48
+ row => row.phase === 'applied' || row.phase === 'confirmed',
49
+ )
50
+ } catch {
51
+ return false
52
+ }
53
+ }
@@ -121,10 +121,11 @@ import { createHash } from 'node:crypto'
121
121
  import { closeSync, constants as fsConstants, fstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs'
122
122
  import { join } from 'node:path'
123
123
  import { agentSessionRoots, idFromCodexFilename } from './agent-session-store.js'
124
+ import { cursorTranscriptPath } from './cursor-agent-store.js'
124
125
  import { isValidNativeThreadId } from './native-thread-id.js'
125
126
 
126
127
  /** Providers with a certified transcript shape. Anything else has no watermark. */
127
- export type NativeHeadProvider = 'claude' | 'codex'
128
+ export type NativeHeadProvider = 'claude' | 'codex' | 'cursor'
128
129
 
129
130
  /**
130
131
  * Token prefix. Bumping it forces every stored head to compare as CHANGED,
@@ -163,6 +164,7 @@ export const TAIL_WINDOW_BYTES = 2 * 1024 * 1024
163
164
  export const TAIL_WINDOW_BYTES_BY_PROVIDER: Record<NativeHeadProvider, number> = {
164
165
  claude: 8 * 1024 * 1024,
165
166
  codex: 64 * 1024 * 1024,
167
+ cursor: 8 * 1024 * 1024,
166
168
  }
167
169
 
168
170
  /**
@@ -185,6 +187,8 @@ export interface NativeHeadDirs {
185
187
  claudeProjectsDir: string
186
188
  /** `<COS_AGENT_SESSIONS_HOME|~>/.codex/sessions` */
187
189
  codexSessionsDir: string
190
+ /** `<COS_AGENT_SESSIONS_HOME|~>/.cursor/projects` */
191
+ cursorProjectsDir: string
188
192
  }
189
193
 
190
194
  export interface TailRead {
@@ -308,6 +312,10 @@ export function isMessageBearingRow(
308
312
  // does not advance on real messages, i.e. divergence that never reports.
309
313
  if (provider === 'claude') return isClaudeMessageRow(row)
310
314
  if (provider === 'codex') return isCodexMessageRow(row)
315
+ if (provider === 'cursor') {
316
+ const role = row.role
317
+ return role === 'user' || role === 'assistant'
318
+ }
311
319
  return false
312
320
  }
313
321
 
@@ -391,12 +399,17 @@ export function transcriptPathFor(
391
399
  if (!isValidNativeThreadId(threadId)) return null
392
400
  if (provider === 'claude') return claudeTranscriptPath(threadId, deps)
393
401
  if (provider === 'codex') return codexRolloutPath(threadId, deps)
402
+ if (provider === 'cursor') return cursorAgentTranscriptPath(threadId, deps)
394
403
  return null
395
404
  } catch {
396
405
  return null
397
406
  }
398
407
  }
399
408
 
409
+ function cursorAgentTranscriptPath(threadId: string, deps: NativeHeadDeps): string | null {
410
+ return cursorTranscriptPath(threadId, deps.dirs.cursorProjectsDir)
411
+ }
412
+
400
413
  function claudeTranscriptPath(threadId: string, deps: NativeHeadDeps): string | null {
401
414
  const root = deps.dirs.claudeProjectsDir
402
415
  if (!deps.dirExists(root)) return null
@@ -494,15 +507,13 @@ export function nativeHead(
494
507
  deps: NativeHeadDeps,
495
508
  ): string | null {
496
509
  try {
497
- if (provider !== 'claude' && provider !== 'codex') return null
510
+ if (provider !== 'claude' && provider !== 'codex' && provider !== 'cursor') return null
498
511
  // Validated before any scan. A truncated or malformed id reaches a
499
512
  // filesystem path below, and "matched nothing" must never look like a
500
513
  // clean read of a real transcript.
501
514
  if (!isValidNativeThreadId(threadId)) return null
502
515
 
503
- const path = provider === 'claude'
504
- ? claudeTranscriptPath(threadId, deps)
505
- : codexRolloutPath(threadId, deps)
516
+ const path = transcriptPathFor(provider, threadId, deps)
506
517
  if (path === null) return null
507
518
 
508
519
  const window = deps.tailWindowBytes ?? TAIL_WINDOW_BYTES_BY_PROVIDER[provider] ?? TAIL_WINDOW_BYTES
@@ -555,7 +566,11 @@ export function nativeHead(
555
566
 
556
567
  export function realNativeHeadDirs(): NativeHeadDirs {
557
568
  const roots = agentSessionRoots()
558
- return { claudeProjectsDir: roots.claudeProjects, codexSessionsDir: roots.codexSessions }
569
+ return {
570
+ claudeProjectsDir: roots.claudeProjects,
571
+ codexSessionsDir: roots.codexSessions,
572
+ cursorProjectsDir: roots.cursorProjects,
573
+ }
559
574
  }
560
575
 
561
576
  function dirExists(path: string): boolean {
@@ -51,6 +51,7 @@ import { basename, join, resolve } from 'node:path'
51
51
  import { NATIVE_THREAD_ID_RE } from './native-thread-id.js'
52
52
  import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
53
53
  import { parseProcStartUtcMs, type OccupancyDirs, type OccupancyProbes } from './thread-occupancy.js'
54
+ import { resolveCursorAgentSession } from './cursor-agent-store.js'
54
55
 
55
56
  // Re-exported rather than reimplemented. `claudeSessionsDir` already encodes the
56
57
  // COS_CLAUDE_SESSIONS_DIR -> CLAUDE_CONFIG_DIR -> ~/.claude precedence AND is the
@@ -73,7 +74,12 @@ export function codexLocksDir(): string {
73
74
  }
74
75
 
75
76
  export function realOccupancyDirs(): OccupancyDirs {
76
- return { claudeSessionsDir: claudeSessionsDir(), codexLocksDir: codexLocksDir() }
77
+ const home = process.env.COS_AGENT_SESSIONS_HOME?.trim() || homedir()
78
+ return {
79
+ claudeSessionsDir: claudeSessionsDir(),
80
+ codexLocksDir: codexLocksDir(),
81
+ cursorChatsDir: join(home, '.cursor', 'chats'),
82
+ }
77
83
  }
78
84
 
79
85
  /** Absolute first, bare name as the fallback: a launchd/Finder-spawned server has no login PATH. */
@@ -441,6 +447,7 @@ export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbe
441
447
  readFile,
442
448
  lockHolders,
443
449
  cosSpawnedPids: () => sanitizeLedger(ledger()),
450
+ cursorAgentSession: (threadId, chatsDir) => resolveCursorAgentSession(threadId, chatsDir),
444
451
  // transcriptMtimeMs is DELIBERATELY absent here. See `withTranscriptClock`.
445
452
  }
446
453
  }
@@ -19,7 +19,7 @@ import { homedir } from 'node:os'
19
19
  // Binary resolution
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
22
+ export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found' | 'unknown_provider'
23
23
 
24
24
  export type BinaryResolution =
25
25
  | { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
@@ -94,7 +94,7 @@ export interface BinarySpec {
94
94
  * `attached-provider-adapter.ts`, and importing it back would recreate exactly the cycle
95
95
  * this extraction exists to break. Callers pass a string-union value, which is assignable.
96
96
  */
97
- export function providerBinarySpec(provider: string): BinarySpec {
97
+ export function providerBinarySpec(provider: string): BinarySpec | null {
98
98
  const home = (() => {
99
99
  try {
100
100
  return homedir()
@@ -113,18 +113,30 @@ export function providerBinarySpec(provider: string): BinarySpec {
113
113
  ].filter(Boolean),
114
114
  }
115
115
  }
116
- return {
117
- name: 'codex',
118
- envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
119
- absolutes: [
120
- // Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
121
- // no `codex` on PATH at all on this machine.
122
- '/Applications/ChatGPT.app/Contents/Resources/codex',
123
- home ? join(home, '.codex', 'bin', 'codex') : '',
124
- '/opt/homebrew/bin/codex',
125
- '/usr/local/bin/codex',
126
- ].filter(Boolean),
116
+ if (provider === 'codex') {
117
+ return {
118
+ name: 'codex',
119
+ envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
120
+ absolutes: [
121
+ // Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
122
+ // no `codex` on PATH at all on this machine.
123
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
124
+ home ? join(home, '.codex', 'bin', 'codex') : '',
125
+ '/opt/homebrew/bin/codex',
126
+ '/usr/local/bin/codex',
127
+ ].filter(Boolean),
128
+ }
129
+ }
130
+ if (provider === 'cursor') {
131
+ return {
132
+ name: 'agent',
133
+ envKeys: ['COS_ATTACHED_CURSOR_AGENT_BIN', 'COS_CURSOR_AGENT_BIN'],
134
+ absolutes: [
135
+ home ? join(home, '.local', 'bin', 'agent') : '',
136
+ ].filter(Boolean),
137
+ }
127
138
  }
139
+ return null
128
140
  }
129
141
 
130
142
  /**
@@ -144,7 +156,9 @@ export function resolveProviderBinary(
144
156
  provider: string,
145
157
  env: NodeJS.ProcessEnv = process.env,
146
158
  ): BinaryResolution {
147
- return resolveBinaryFromSpec(providerBinarySpec(provider), env)
159
+ const spec = providerBinarySpec(provider)
160
+ if (!spec) return { ok: false, binary: provider, detail: 'unknown_provider' }
161
+ return resolveBinaryFromSpec(spec, env)
148
162
  }
149
163
 
150
164
  /**
@@ -43,10 +43,11 @@
43
43
  // owns the endpoints "so it cannot drift from whether they are actually
44
44
  // registered."
45
45
  //
46
- // `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor must
47
- // not appear (plan 2.5 makes it Fork-only), and the way to guarantee that is to
48
- // read the list that `isBindableProvider` the check the attach route itself
49
- // applies is built from, rather than restating two names here and hoping.
46
+ // `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor is
47
+ // Continue-capable once bindable; Fork stays on FORKABLE_PROVIDERS (claude/codex).
48
+ // The way to guarantee the published list matches the attach route is to read
49
+ // the list that `isBindableProvider` is built from, rather than restating names
50
+ // here and hoping.
50
51
  //
51
52
  // ------------------------------------------------- why providers empties out
52
53
  //
@@ -86,7 +87,7 @@ export interface ThreadAttachCapability {
86
87
  /**
87
88
  * Providers that can be CONTINUED, not merely browsed or forked.
88
89
  *
89
- * Empty whenever `enabled` is false. Cursor is never a member: it is Fork-only.
90
+ * Empty whenever `enabled` is false. Cursor is a member once bindable.
90
91
  */
91
92
  providers: BindableProvider[]
92
93
  /**
@@ -150,6 +150,16 @@ export interface OccupancyProbes {
150
150
  * safety property here.
151
151
  */
152
152
  transcriptMtimeMs?: (provider: OccupancyProvider, threadId: string) => number | null
153
+ /**
154
+ * Cursor Agent CLI session at `~/.cursor/chats/<hash>/<id>/`, or null when
155
+ * that id is not exactly one continuable chats dir. Occupancy MUST NOT import
156
+ * fs; this is the only Cursor evidence this detector is allowed to see.
157
+ */
158
+ cursorAgentSession?: (threadId: string, chatsDir: string) => {
159
+ dir: string
160
+ cwd: string
161
+ hasConversation: boolean
162
+ } | null
153
163
  }
154
164
 
155
165
  /**
@@ -464,6 +474,8 @@ export interface OccupancyDirs {
464
474
  claudeSessionsDir: string
465
475
  /** `<CODEX_HOME|~/.codex>/thread-writer-locks` */
466
476
  codexLocksDir: string
477
+ /** `<COS_AGENT_SESSIONS_HOME|~>/.cursor/chats` */
478
+ cursorChatsDir: string
467
479
  }
468
480
 
469
481
  // ===========================================================================
@@ -551,8 +563,26 @@ export function threadOccupancy(
551
563
  probes: OccupancyProbes,
552
564
  dirs: OccupancyDirs,
553
565
  ): Occupancy {
566
+ if (provider === 'cursor') {
567
+ if (!isValidNativeThreadId(threadId)) {
568
+ return { attachable: false, owners: [], reason: 'invalid_thread_id' }
569
+ }
570
+ if (typeof probes.cursorAgentSession !== 'function' || typeof dirs.cursorChatsDir !== 'string') {
571
+ return { attachable: false, owners: [], reason: 'probe_failed' }
572
+ }
573
+ let session: { dir: string; cwd: string; hasConversation: boolean } | null
574
+ try {
575
+ session = probes.cursorAgentSession(threadId, dirs.cursorChatsDir)
576
+ } catch {
577
+ return { attachable: false, owners: [], reason: 'probe_failed' }
578
+ }
579
+ if (!session || session.hasConversation !== true) {
580
+ return { attachable: false, owners: [], reason: 'unsupported_provider' }
581
+ }
582
+ return { attachable: true, owners: [], reason: null }
583
+ }
554
584
  if (provider !== 'claude' && provider !== 'codex') {
555
- // Cursor and anything unrecognised: an honest capability gap, not a failure.
585
+ // Anything unrecognised: an honest capability gap, not a failure.
556
586
  return { attachable: false, owners: [], reason: 'unsupported_provider' }
557
587
  }
558
588
  // Validated BEFORE any scan. A truncated or malformed id matches no record,
@@ -109,11 +109,13 @@ import {
109
109
  assertUsable,
110
110
  boundToMarker,
111
111
  isBindableProvider,
112
+ isForkableProvider,
112
113
  isExpired,
113
114
  isPinned,
114
115
  isTerminal,
115
116
  targetKey,
116
117
  type BindableProvider,
118
+ type ForkableProvider,
117
119
  type BindingState,
118
120
  type NativeBinding,
119
121
  } from '../lib/agent-session-binding-store.js'
@@ -798,9 +800,11 @@ function occupancyDepsUsable(deps: AgentSessionBindingsDeps): boolean {
798
800
  typeof probes.fileExists === 'function' &&
799
801
  typeof probes.lockHolders === 'function' &&
800
802
  typeof probes.cosSpawnedPids === 'function' &&
803
+ typeof probes.cursorAgentSession === 'function' &&
801
804
  !!dirs &&
802
805
  typeof dirs.claudeSessionsDir === 'string' &&
803
- typeof dirs.codexLocksDir === 'string'
806
+ typeof dirs.codexLocksDir === 'string' &&
807
+ typeof dirs.cursorChatsDir === 'string'
804
808
  )
805
809
  }
806
810
 
@@ -1097,7 +1101,7 @@ export function isOpaque(value: unknown): value is string {
1097
1101
 
1098
1102
  /** What the route hands `forkThread`. The client supplies none of these but the prompt. */
1099
1103
  export interface ForkRouteRequest {
1100
- provider: BindableProvider
1104
+ provider: ForkableProvider
1101
1105
  nativeThreadId: string
1102
1106
  prompt: string
1103
1107
  /** Resolved server-side. Plan 4.2: the client never sends a path. */
@@ -1734,7 +1738,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1734
1738
  // Validated HERE rather than inherited from an occupancy verdict, because
1735
1739
  // this route deliberately never asks for one. The id becomes a spawn
1736
1740
  // argument and a lock key, so `isValidNativeThreadId` is the whole guard.
1737
- if (!isBindableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
1741
+ if (!isForkableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
1738
1742
  if (!isValidNativeThreadId(threadIdParam)) return refuseFork('fork_invalid_thread_id')
1739
1743
 
1740
1744
  const now = readNow()
@@ -39,6 +39,7 @@ import {
39
39
  occupiedThreads,
40
40
  noOccupancyKnown,
41
41
  withActiveRecently,
42
+ isActiveRecently,
42
43
  type OccupiedScan,
43
44
  type OccupiedThread,
44
45
  } from '../lib/occupied-threads.js'
@@ -209,6 +210,16 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
209
210
  * gate; `attach` re-probes at the write, unchanged.
210
211
  */
211
212
  function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: number): OccupiedScan {
213
+ // Cursor: no process registry. The detail handler already stat'ed the jsonl;
214
+ // freshness of that file is the only honest working signal for the lens.
215
+ // Occupancy (the write gate) uses chats-dir resolution, not this hint.
216
+ if (provider === 'cursor') {
217
+ if (!isActiveRecently(mtimeMs, Date.now())) return { occupied: new Map(), degraded: false }
218
+ return {
219
+ occupied: new Map([[threadId, { threadId, owners: 1, foreignOwners: 0, activeRecently: true }]]),
220
+ degraded: false,
221
+ }
222
+ }
212
223
  if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
213
224
  try {
214
225
  const scan = occupiedThreads(
@@ -275,8 +286,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
275
286
  const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort, dropped)
276
287
  const scan = runningThreads(sessions)
277
288
  // Freshness is layered on AFTER occupancy, and only over what occupancy
278
- // found. `Date.now()` is read once so every row in a payload is judged
279
- // against the same instant.
289
+ // found. Cursor has no process occupancy, so the list does not invent a
290
+ // working hint from jsonl mtime — detail still can, from the file it stat'ed.
280
291
  const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions), Date.now())
281
292
  res.json({
282
293
  sessions: sessions.map(row => withRunning(toEntry(row), running)),
@@ -995,9 +995,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
995
995
  *
996
996
  * SAME GATES, no exceptions. It calls `enrolNamedVoice`, so raw-index mapping,
997
997
  * refusal when unmappable, voice coherence, the diversity cap and the
998
- * `correction:<sessionId>` tag all apply identically. Rows whose `from` is a real
999
- * person are skipped by that function's own placeholder rule, which is what keeps a
1000
- * mis-attribution correction (Allison Wheeler -> Kirstyn) out of the training set.
998
+ * `correction:<sessionId>` tag all apply identically. A named `from` is still
999
+ * enrolled when `to` is a real person that is how a wrong existing match
1000
+ * becomes a new profile (Nick Gurney Milo LeBaron). Placeholder targets
1001
+ * stay idle.
1001
1002
  *
1002
1003
  * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
1003
1004
  * nothing.
@@ -206,6 +206,10 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
206
206
  return res.status(400).json({ error: 'invalid_request' })
207
207
  }
208
208
 
209
+ if (provider === 'cursor') {
210
+ return res.status(423).json({ error: 'unsupported_provider', queueable: false })
211
+ }
212
+
209
213
  // THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
210
214
  // clear, and telling someone their turn is queued when it can never run is worse
211
215
  // than refusing it. An attachable thread is not queued either -- it is sent now,