@gotcos/glasses-server 6.36.13 → 6.36.17
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 +50 -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 +56 -13
- package/server/lib/attached-provider-adapter.ts +40 -7
- package/server/lib/attached-workspace.ts +36 -1
- 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/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 +24 -5
- package/server/routes/meeting.ts +4 -3
- package/server/routes/thread-turn-queue.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,53 @@
|
|
|
1
|
-
##
|
|
1
|
+
## 6.36.17
|
|
2
|
+
- **Naming a new person from a wrong existing label now creates their voice profile.**
|
|
3
|
+
Enrolment after `POST /relabel` only fired when `from` was a placeholder (`Ext`,
|
|
4
|
+
`Unknown`, `Unidentified N`). The live path in Speakers review is the other one:
|
|
5
|
+
the identifier weakly matches someone already enrolled, and the reviewer says
|
|
6
|
+
**This is someone else → Use "Milo LeBaron"**. Measured 2026-08-20 on
|
|
7
|
+
`meeting_1787234635703_t4iz74`: Nick Gurney → Milo LeBaron, 19 chunks, ledger
|
|
8
|
+
`applied`, 78 profiles, no Milo. Backfill used the same guard, so the meeting
|
|
9
|
+
could not be enrolled after the fact either (`eligible: 0`, `skippedNamedSource: 1`).
|
|
10
|
+
- **Enrol by target, not by source.** `enrolNamedVoice` still skips a placeholder
|
|
11
|
+
`to` and an empty `changed` list. A real `to` enrols those chunks — creates the
|
|
12
|
+
profile when it does not exist, appends when it does — through the same raw-index
|
|
13
|
+
map, coherence gate, 20-sample cap, and `correction:<sessionId>` tag. Global fold
|
|
14
|
+
of two identities remains `merge-profiles`. Per-meeting chunk assignment is not
|
|
15
|
+
that. Mutating the old `from`-placeholder guard back in fails the new Nick → Milo
|
|
16
|
+
test.
|
|
17
|
+
|
|
18
|
+
## 6.36.16
|
|
19
|
+
- **Cursor Agent Continue.** Continue now resumes a Cursor Agent CLI thread with
|
|
20
|
+
`agent --resume <id> --workspace <spawn spelling>` in ask-mode. Bindable, not
|
|
21
|
+
forkable: Fork on Cursor still has no spawn path. Occupancy treats a resolved
|
|
22
|
+
`~/.cursor/chats/<hash>/<id>/` session (`hasConversation: true`) as attachable
|
|
23
|
+
with no invented process owner. `--workspace` uses the jsonl folder slug that
|
|
24
|
+
already exists — never `realpath` of `meta.json.cwd`, which creates a second
|
|
25
|
+
transcript folder. Queue is refused for Cursor. The 6.36.15 jsonl-mtime write
|
|
26
|
+
hint is unpublished on the LIST; detail still uses the jsonl mtime the handler
|
|
27
|
+
already stat'ed as a display-only working signal, never a write gate.
|
|
28
|
+
|
|
29
|
+
## 6.36.15 (unpublished)
|
|
30
|
+
- **Cursor sessions can show as working.** Occupancy only scanned Claude
|
|
31
|
+
(registry) and Codex (writer lock). Cursor has neither, so every Cursor row
|
|
32
|
+
stamped `running: false` even while the jsonl was being written — the lens
|
|
33
|
+
stayed on the digest and the list never showed a live Cursor turn. The list
|
|
34
|
+
already paid for Cursor `modified` as the jsonl mtime; a write inside the same
|
|
35
|
+
30s window Claude/Codex use for `running_active` now synthesizes a display
|
|
36
|
+
hint (`running` + `running_active`, `running_foreign` still false). Detail
|
|
37
|
+
uses the file it already stat'ed. Still a hint, never a write gate. Withheld:
|
|
38
|
+
Continue on Cursor is 6.36.16, and this mtime hint is not in that train.
|
|
39
|
+
|
|
40
|
+
## 6.36.14
|
|
41
|
+
- **The session LIST now reports what its caps hid.** The 7-day age gate, the
|
|
42
|
+
20-per-provider cap, and the Cursor 32 MB skip used to drop rows with no
|
|
43
|
+
signal, so a 60-row list looked complete. `GET /api/agent-sessions` now carries
|
|
44
|
+
`dropped: { age, limit, oversized }`. Additive: an older client ignores the
|
|
45
|
+
key. Zero means the walk found nothing to hide, not that the caps are off.
|
|
46
|
+
Keep-warm titles and Codex files over 32 MB are not counted — Codex is listed
|
|
47
|
+
oversize on purpose; Cursor is the one that skips. Measured on this machine
|
|
48
|
+
before publish: 62 listed, **2,144** older than 7 days, **91** over the
|
|
49
|
+
per-provider cap, **0** oversized. The visible first/last ids match running
|
|
50
|
+
6.36.13, so the list membership did not change — only the silence did.
|
|
2
51
|
|
|
3
52
|
## 6.36.13
|
|
4
53
|
- **Codex was spawned by bare name in four places, and it only worked here by accident.**
|
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
|
*
|
|
@@ -27,6 +27,17 @@ const CODEX_ROLLOUT_STAMP = /^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}
|
|
|
27
27
|
export type AgentProvider = 'claude' | 'codex' | 'cursor'
|
|
28
28
|
export type AgentSessionSort = 'updated' | 'opened'
|
|
29
29
|
|
|
30
|
+
/** Caps that drop LIST rows with no other signal. Search has its own budget. */
|
|
31
|
+
export interface AgentSessionListDropped {
|
|
32
|
+
age: number
|
|
33
|
+
limit: number
|
|
34
|
+
oversized: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function emptySessionListDropped(): AgentSessionListDropped {
|
|
38
|
+
return { age: 0, limit: 0, oversized: 0 }
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export interface AgentSessionRow {
|
|
31
42
|
session_id: string
|
|
32
43
|
provider: AgentProvider
|
|
@@ -47,6 +58,7 @@ export interface AgentSessionRoots {
|
|
|
47
58
|
claudeCodeSessions: string
|
|
48
59
|
codexSessions: string
|
|
49
60
|
cursorProjects: string
|
|
61
|
+
cursorChats: string
|
|
50
62
|
cursorComposerDb: string
|
|
51
63
|
cursorWorkspaceStorage: string
|
|
52
64
|
}
|
|
@@ -58,6 +70,7 @@ export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME ||
|
|
|
58
70
|
claudeCodeSessions: join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions'),
|
|
59
71
|
codexSessions: join(home, '.codex', 'sessions'),
|
|
60
72
|
cursorProjects: join(home, '.cursor', 'projects'),
|
|
73
|
+
cursorChats: join(home, '.cursor', 'chats'),
|
|
61
74
|
cursorComposerDb: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
|
62
75
|
cursorWorkspaceStorage: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'),
|
|
63
76
|
}
|
|
@@ -836,6 +849,7 @@ export async function listClaudeSessions(
|
|
|
836
849
|
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
837
850
|
starredIds: ReadonlySet<string> = new Set(),
|
|
838
851
|
desktopSessionsRoot = '',
|
|
852
|
+
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
839
853
|
): Promise<AgentSessionRow[]> {
|
|
840
854
|
const seen = new Set<string>()
|
|
841
855
|
const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
@@ -854,7 +868,10 @@ export async function listClaudeSessions(
|
|
|
854
868
|
if (!st?.isFile) continue
|
|
855
869
|
const pinned = starredIds.has(native.toLowerCase())
|
|
856
870
|
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
857
|
-
if (!pinned && !fresh)
|
|
871
|
+
if (!pinned && !fresh) {
|
|
872
|
+
dropped.age += 1
|
|
873
|
+
continue
|
|
874
|
+
}
|
|
858
875
|
const candidate = {
|
|
859
876
|
file,
|
|
860
877
|
native,
|
|
@@ -888,8 +905,12 @@ export async function listClaudeSessions(
|
|
|
888
905
|
|
|
889
906
|
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
890
907
|
const rows: AgentSessionRow[] = []
|
|
891
|
-
for (
|
|
892
|
-
if (rows.length >= Math.max(0, limit))
|
|
908
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
909
|
+
if (rows.length >= Math.max(0, limit)) {
|
|
910
|
+
dropped.limit += candidates.length - i
|
|
911
|
+
break
|
|
912
|
+
}
|
|
913
|
+
const candidate = candidates[i]
|
|
893
914
|
let title = ''
|
|
894
915
|
let project = candidate.project
|
|
895
916
|
let firstPrompt = ''
|
|
@@ -935,6 +956,7 @@ export async function listCodexSessions(
|
|
|
935
956
|
sessionsRoot: string,
|
|
936
957
|
now: Date,
|
|
937
958
|
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
959
|
+
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
938
960
|
): Promise<AgentSessionRow[]> {
|
|
939
961
|
const names = await loadCodexThreadNames(sessionsRoot)
|
|
940
962
|
const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
|
|
@@ -947,7 +969,10 @@ export async function listCodexSessions(
|
|
|
947
969
|
const fileId = idFromCodexFilename(name)
|
|
948
970
|
const pinned = fileId ? pinnedIds.has(fileId) : false
|
|
949
971
|
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
950
|
-
if (!pinned && !fresh)
|
|
972
|
+
if (!pinned && !fresh) {
|
|
973
|
+
dropped.age += 1
|
|
974
|
+
continue
|
|
975
|
+
}
|
|
951
976
|
const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
|
|
952
977
|
if (pinned) pinnedCandidates.push(candidate)
|
|
953
978
|
else recentCandidates.push(candidate)
|
|
@@ -957,8 +982,12 @@ export async function listCodexSessions(
|
|
|
957
982
|
|
|
958
983
|
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
959
984
|
const rows: AgentSessionRow[] = []
|
|
960
|
-
for (
|
|
961
|
-
if (rows.length >= Math.max(0, limit))
|
|
985
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
986
|
+
if (rows.length >= Math.max(0, limit)) {
|
|
987
|
+
dropped.limit += candidates.length - i
|
|
988
|
+
break
|
|
989
|
+
}
|
|
990
|
+
const candidate = candidates[i]
|
|
962
991
|
const meta = await peekCodexMeta(candidate.file)
|
|
963
992
|
if (!meta || meta.subagent) continue
|
|
964
993
|
// THE FILENAME WINS WHEN THEY DISAGREE.
|
|
@@ -1011,6 +1040,7 @@ export async function listCursorSessions(
|
|
|
1011
1040
|
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
1012
1041
|
composerDb = '',
|
|
1013
1042
|
pinnedIds: ReadonlySet<string> = new Set(),
|
|
1043
|
+
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
1014
1044
|
): Promise<AgentSessionRow[]> {
|
|
1015
1045
|
const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
|
|
1016
1046
|
const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
|
|
@@ -1024,9 +1054,15 @@ export async function listCursorSessions(
|
|
|
1024
1054
|
const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
|
|
1025
1055
|
const st = await fileStat(file)
|
|
1026
1056
|
if (!st?.isFile) continue
|
|
1027
|
-
if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES)
|
|
1057
|
+
if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) {
|
|
1058
|
+
dropped.oversized += 1
|
|
1059
|
+
continue
|
|
1060
|
+
}
|
|
1028
1061
|
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
1029
|
-
if (!pinned && !fresh)
|
|
1062
|
+
if (!pinned && !fresh) {
|
|
1063
|
+
dropped.age += 1
|
|
1064
|
+
continue
|
|
1065
|
+
}
|
|
1030
1066
|
const next = {
|
|
1031
1067
|
file,
|
|
1032
1068
|
sessionDir,
|
|
@@ -1045,8 +1081,12 @@ export async function listCursorSessions(
|
|
|
1045
1081
|
|
|
1046
1082
|
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
1047
1083
|
const rows: AgentSessionRow[] = []
|
|
1048
|
-
for (
|
|
1049
|
-
if (rows.length >= Math.max(0, limit))
|
|
1084
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1085
|
+
if (rows.length >= Math.max(0, limit)) {
|
|
1086
|
+
dropped.limit += candidates.length - i
|
|
1087
|
+
break
|
|
1088
|
+
}
|
|
1089
|
+
const candidate = candidates[i]
|
|
1050
1090
|
const peek = await peekCursorDiscussion(candidate.file)
|
|
1051
1091
|
const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
|
|
1052
1092
|
const title = composerNames.get(candidate.sessionDir)
|
|
@@ -1141,6 +1181,7 @@ export async function listAgentSessions(
|
|
|
1141
1181
|
live: AgentSessionRow[] = [],
|
|
1142
1182
|
limit = AGENT_SESSION_LIST_LIMIT,
|
|
1143
1183
|
sort: AgentSessionSort = 'updated',
|
|
1184
|
+
dropped: AgentSessionListDropped = emptySessionListDropped(),
|
|
1144
1185
|
): Promise<AgentSessionRow[]> {
|
|
1145
1186
|
const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
1146
1187
|
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
@@ -1159,9 +1200,9 @@ export async function listAgentSessions(
|
|
|
1159
1200
|
const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
|
|
1160
1201
|
let rows = dedupeSessions([
|
|
1161
1202
|
...enrichedLive,
|
|
1162
|
-
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
|
|
1163
|
-
...await listCodexSessions(roots.codexSessions, now, cap),
|
|
1164
|
-
...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
|
|
1203
|
+
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped),
|
|
1204
|
+
...await listCodexSessions(roots.codexSessions, now, cap, dropped),
|
|
1205
|
+
...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
|
|
1165
1206
|
])
|
|
1166
1207
|
if (sort === 'opened') {
|
|
1167
1208
|
rows = rows.filter(entry => {
|
|
@@ -1178,6 +1219,8 @@ export async function listAgentSessions(
|
|
|
1178
1219
|
// pin-boosting here made the lens show July stars instead of today.
|
|
1179
1220
|
rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
|
|
1180
1221
|
}
|
|
1222
|
+
const extras = Math.max(0, rows.length - Math.max(0, limit))
|
|
1223
|
+
if (extras) dropped.limit += extras
|
|
1181
1224
|
return rows.slice(0, limit)
|
|
1182
1225
|
}
|
|
1183
1226
|
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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()
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// `?sort=opened` keeps the same window on session start instead.
|
|
12
12
|
// Search scans titles, sidebar names, first prompts, and transcript heads
|
|
13
13
|
// without the 7-day list window. Literal /search is registered first.
|
|
14
|
-
// Does not need COS_SCRIPTS_DIR. Codex subagents stay out.
|
|
15
|
-
// still appear in the list
|
|
14
|
+
// Does not need COS_SCRIPTS_DIR. Codex subagents stay out.
|
|
15
|
+
// Codex files over 32 MB still appear in the list. Cursor files over 32 MB
|
|
16
|
+
// are skipped and counted on `dropped.oversized`. The list payload now says
|
|
17
|
+
// what each cap hid.
|
|
16
18
|
|
|
17
19
|
import { Router } from 'express'
|
|
18
20
|
import { stat } from 'node:fs/promises'
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
agentSessionRoots,
|
|
24
26
|
findAgentSessionFile,
|
|
25
27
|
listAgentSessions,
|
|
28
|
+
emptySessionListDropped,
|
|
26
29
|
loadCursorComposerNames,
|
|
27
30
|
parseAgentSession,
|
|
28
31
|
type AgentProvider,
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
occupiedThreads,
|
|
37
40
|
noOccupancyKnown,
|
|
38
41
|
withActiveRecently,
|
|
42
|
+
isActiveRecently,
|
|
39
43
|
type OccupiedScan,
|
|
40
44
|
type OccupiedThread,
|
|
41
45
|
} from '../lib/occupied-threads.js'
|
|
@@ -206,6 +210,16 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
|
|
|
206
210
|
* gate; `attach` re-probes at the write, unchanged.
|
|
207
211
|
*/
|
|
208
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
|
+
}
|
|
209
223
|
if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
|
|
210
224
|
try {
|
|
211
225
|
const scan = occupiedThreads(
|
|
@@ -268,11 +282,12 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
268
282
|
const limit = boundedInteger(req.query.limit, AGENT_SESSION_LIST_LIMIT, 1, AGENT_SESSION_LIST_MAX)
|
|
269
283
|
const sort = asSort(req.query.sort)
|
|
270
284
|
const live = await liveClaudeRows()
|
|
271
|
-
const
|
|
285
|
+
const dropped = emptySessionListDropped()
|
|
286
|
+
const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort, dropped)
|
|
272
287
|
const scan = runningThreads(sessions)
|
|
273
288
|
// Freshness is layered on AFTER occupancy, and only over what occupancy
|
|
274
|
-
// found.
|
|
275
|
-
//
|
|
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.
|
|
276
291
|
const running = withActiveRecently(scan, await transcriptMtimes(scan, sessions), Date.now())
|
|
277
292
|
res.json({
|
|
278
293
|
sessions: sessions.map(row => withRunning(toEntry(row), running)),
|
|
@@ -283,6 +298,10 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
283
298
|
// True when a probe could not see clearly. The client must render "unknown"
|
|
284
299
|
// rather than treating a quiet scan as "nothing is running".
|
|
285
300
|
runningDegraded: running.degraded,
|
|
301
|
+
// LIST caps that previously dropped rows with no signal. Additive: an older
|
|
302
|
+
// client ignores this key. Zero means the walk found nothing to hide, not
|
|
303
|
+
// that the caps are off.
|
|
304
|
+
dropped,
|
|
286
305
|
})
|
|
287
306
|
} catch (error) {
|
|
288
307
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
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,
|