@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 +44 -1
- package/package.json +1 -1
- package/server/index.ts +1 -1
- package/server/lib/agent-session-binding-store.ts +10 -2
- package/server/lib/agent-session-store.ts +2 -0
- package/server/lib/attached-provider-adapter.ts +40 -7
- package/server/lib/attached-workspace.ts +36 -1
- package/server/lib/cos-operations-meetings.ts +19 -10
- package/server/lib/cursor-agent-store.ts +189 -0
- package/server/lib/cursor-model-catalog.ts +4 -14
- package/server/lib/fork-thread.ts +7 -8
- package/server/lib/meeting-relabel-enrolment.ts +18 -9
- package/server/lib/meeting-store.ts +7 -0
- package/server/lib/meeting-voice-review.ts +53 -0
- package/server/lib/native-head.ts +21 -6
- package/server/lib/occupancy-probes.ts +8 -1
- package/server/lib/provider-binary.ts +28 -14
- package/server/lib/thread-attach-capability.ts +6 -5
- package/server/lib/thread-occupancy.ts +31 -1
- package/server/routes/agent-session-bindings.ts +7 -3
- package/server/routes/agent-sessions.ts +13 -2
- package/server/routes/meeting.ts +4 -3
- package/server/routes/thread-turn-queue.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
|
-
##
|
|
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
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
|
|
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
|
|
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
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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 (
|
|
498
|
-
const value = String(
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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
|
|
165
|
+
if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return { speakers: [] }
|
|
162
166
|
const real = realpathSync(path)
|
|
163
|
-
if (dirname(real) !== realpathSync(monthDir)) return
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
96
|
+
/** Resolve the Cursor `agent` binary via the shared provider table. */
|
|
97
97
|
export function resolveAgentBinary(): string | undefined {
|
|
98
|
-
const
|
|
99
|
-
|
|
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 {
|
|
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.
|
|
91
|
+
* Providers that can be forked. A strict subset of the attached set.
|
|
93
92
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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 =
|
|
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 (!
|
|
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
|
|
90
|
-
*
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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 (!
|
|
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.
|
|
279
|
-
//
|
|
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)),
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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.
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
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,
|