@gotcos/glasses-server 6.61.7 → 6.62.1
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/.env.example +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
// registered."
|
|
45
45
|
//
|
|
46
46
|
// `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor is
|
|
47
|
-
// Continue-capable once bindable; Fork
|
|
47
|
+
// Continue-capable once bindable; Fork reads FORKABLE_PROVIDERS (6.62.0: Cursor too, as a new
|
|
48
|
+
// read-only session with context).
|
|
48
49
|
// The way to guarantee the published list matches the attach route is to read
|
|
49
50
|
// the list that `isBindableProvider` is built from, rather than restating names
|
|
50
51
|
// here and hoping.
|
|
@@ -64,8 +65,10 @@
|
|
|
64
65
|
|
|
65
66
|
import {
|
|
66
67
|
BINDABLE_PROVIDERS,
|
|
68
|
+
FORKABLE_PROVIDERS,
|
|
67
69
|
isBindableProvider,
|
|
68
70
|
type BindableProvider,
|
|
71
|
+
type ForkableProvider,
|
|
69
72
|
} from './agent-session-binding-store.js'
|
|
70
73
|
import { threadAttachEnabled } from '../routes/agent-session-bindings.js'
|
|
71
74
|
|
|
@@ -98,6 +101,13 @@ export interface ThreadAttachCapability {
|
|
|
98
101
|
* incoherent default: every refusal recommends Fork while the route 404s.
|
|
99
102
|
*/
|
|
100
103
|
forkSupported: boolean
|
|
104
|
+
/**
|
|
105
|
+
* 6.62.0 (plan 3.7, W10): WHICH providers the fork route serves. `forkSupported` stays a
|
|
106
|
+
* boolean for every reader that already checks it with `=== true`; this list is new.
|
|
107
|
+
* Sourced from FORKABLE_PROVIDERS, the list `isForkableProvider` is built from. Cursor's
|
|
108
|
+
* entry is "New session with this context" (a new read-only chat), not a native fork.
|
|
109
|
+
*/
|
|
110
|
+
forkProviders?: ForkableProvider[]
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/**
|
|
@@ -129,6 +139,7 @@ export function threadAttachCapability(
|
|
|
129
139
|
// byte-identical, so the flag protecting an existing conversation does not
|
|
130
140
|
// apply. Gating it made every refusal recommend a route that 404s.
|
|
131
141
|
forkSupported: THREAD_ATTACH_SUPPORTED,
|
|
142
|
+
forkProviders: THREAD_ATTACH_SUPPORTED ? [...FORKABLE_PROVIDERS] : [],
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
145
|
|
|
@@ -139,6 +150,8 @@ export interface ThreadAttachHealthFields {
|
|
|
139
150
|
/** Fork is available whenever the build supports it, gate or no gate. */
|
|
140
151
|
threadForkSupported: boolean
|
|
141
152
|
threadAttachProviders: BindableProvider[]
|
|
153
|
+
/** 6.62.0: the providers the fork route serves (`threadForkSupported` stays the boolean). */
|
|
154
|
+
threadForkProviders: ForkableProvider[]
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
/** Flatten the capability onto the health payload's key names. */
|
|
@@ -150,6 +163,7 @@ export function threadAttachHealthFields(
|
|
|
150
163
|
threadAttachEnabled: capability.enabled,
|
|
151
164
|
threadAttachProviders: capability.providers,
|
|
152
165
|
threadForkSupported: capability.forkSupported,
|
|
166
|
+
threadForkProviders: capability.forkSupported ? [...(capability.forkProviders ?? [])] : [],
|
|
153
167
|
}
|
|
154
168
|
}
|
|
155
169
|
|
|
@@ -70,11 +70,13 @@ export type KickPlan = { kind: 'kick' } | { kind: 'kick_when_registry_idle'; sto
|
|
|
70
70
|
* - a SubagentStop never kicks: a background sub-agent finishing after the Stop is
|
|
71
71
|
* answered by the model in a turn of its own, whose Stop kicks, and neither the hook
|
|
72
72
|
* short-circuit nor the B6 clause vouches for a session whose newest event is a
|
|
73
|
-
* SubagentStop, so a kick there could only fall through to the 30 s backstop
|
|
73
|
+
* SubagentStop, so a kick there could only fall through to the 30 s backstop;
|
|
74
|
+
* - 6.62.0: a Codex `Interrupt` ends a turn the way a Stop does (Esc in Codex fires no
|
|
75
|
+
* Stop), so it follows the same rule; no Claude or Cursor hook emits it.
|
|
74
76
|
*/
|
|
75
77
|
export function kickPlanFor(signal: KickSignalFacts, event: string, eventTs: number, child: boolean): KickPlan {
|
|
76
78
|
if (child) return event === 'SessionEnd' ? { kind: 'kick' } : null
|
|
77
|
-
if (event !== 'Stop') return null
|
|
79
|
+
if (event !== 'Stop' && event !== 'Interrupt') return null
|
|
78
80
|
if (signal.turnOpen || signal.subagentsOpen > 0 || signal.ended) return null
|
|
79
81
|
return { kind: 'kick_when_registry_idle', stopAt: typeof signal.stopAt === 'number' ? signal.stopAt : eventTs }
|
|
80
82
|
}
|
|
@@ -93,6 +93,14 @@ export interface Occupancy {
|
|
|
93
93
|
* Absent (not false) on every other verdict, so `=== true` is the only test.
|
|
94
94
|
*/
|
|
95
95
|
idleHolder?: true
|
|
96
|
+
/**
|
|
97
|
+
* 6.62.0 (plan 3.6, W8): attachable DESPITE a foreign owner that is WORKING, because the
|
|
98
|
+
* owner is the Codex app and the turn goes into that app's own queue (`codex queue`),
|
|
99
|
+
* which runs it at the end of the running turn. Never set by `threadOccupancy`: only the
|
|
100
|
+
* bindings route declares it, and only when the hop is wired, on, and COS holds no turn
|
|
101
|
+
* of its own for the thread. Same `=== true` contract as `idleHolder`.
|
|
102
|
+
*/
|
|
103
|
+
busyHolder?: true
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
/**
|
|
@@ -191,8 +199,25 @@ export interface OccupancyProbes {
|
|
|
191
199
|
* absent, null, or a throw keeps the 6.50 verdict (`unsupported_provider`).
|
|
192
200
|
*/
|
|
193
201
|
cursorComposerTurnOpen?: (threadId: string) => boolean | null
|
|
202
|
+
/**
|
|
203
|
+
* 6.62.0 (W14): the newest write to a Cursor CLI chat (`store.db`, its WAL, or the agent
|
|
204
|
+
* transcript), epoch ms, or null. A `-p` run fires no turn hooks (canary C7), so file
|
|
205
|
+
* times are the only busy signal a CLI chat has, and a Run Everything Continue must not
|
|
206
|
+
* run at the same moment as the person's own terminal run. OPTIONAL; absent or null
|
|
207
|
+
* keeps the 6.61 verdict.
|
|
208
|
+
*/
|
|
209
|
+
cursorChatLastWriteMs?: (chatDir: string, threadId: string) => number | null
|
|
210
|
+
/**
|
|
211
|
+
* 6.62.0 /qa (W7): does a process that is NOT COS hold this CLI chat's `store.db` open?
|
|
212
|
+
* True is positive evidence of a run (a terminal `agent`, a desk run quiet past 30 s).
|
|
213
|
+
* Null or a throw: cannot tell, and the 30 s write window alone decides.
|
|
214
|
+
*/
|
|
215
|
+
cursorChatForeignHolder?: (chatDir: string) => boolean | null
|
|
194
216
|
}
|
|
195
217
|
|
|
218
|
+
/** 6.62.0 (W14): a CLI chat written this recently is working; a turn for it queues. */
|
|
219
|
+
export const CURSOR_CHAT_BUSY_WINDOW_MS = 30_000
|
|
220
|
+
|
|
196
221
|
/**
|
|
197
222
|
* How recently the transcript must have been written for a held thread to read
|
|
198
223
|
* as WORKING rather than merely OPEN.
|
|
@@ -637,6 +662,25 @@ export function threadOccupancy(
|
|
|
637
662
|
}
|
|
638
663
|
return { attachable: false, owners: [], reason: open === true ? 'native_thread_working' : 'unsupported_provider' }
|
|
639
664
|
}
|
|
665
|
+
// 6.62.0 (W14): a CLI chat written in the last 30 s is mid-run, at the desk or by a
|
|
666
|
+
// COS child that just finished; a turn spoken at it waits rather than running
|
|
667
|
+
// alongside. A probe that throws or cannot tell keeps the 6.61 verdict.
|
|
668
|
+
let lastWrite: number | null = null
|
|
669
|
+
try {
|
|
670
|
+
lastWrite = typeof probes.cursorChatLastWriteMs === 'function' ? probes.cursorChatLastWriteMs(session.dir, threadId) : null
|
|
671
|
+
} catch {
|
|
672
|
+
lastWrite = null
|
|
673
|
+
}
|
|
674
|
+
if (typeof lastWrite === 'number' && Number.isFinite(lastWrite) && Date.now() - lastWrite < CURSOR_CHAT_BUSY_WINDOW_MS) {
|
|
675
|
+
return { attachable: false, owners: [], reason: 'native_thread_working' }
|
|
676
|
+
}
|
|
677
|
+
let held: boolean | null = null
|
|
678
|
+
try {
|
|
679
|
+
held = typeof probes.cursorChatForeignHolder === 'function' ? probes.cursorChatForeignHolder(session.dir) : null
|
|
680
|
+
} catch {
|
|
681
|
+
held = null
|
|
682
|
+
}
|
|
683
|
+
if (held === true) return { attachable: false, owners: [], reason: 'native_thread_working' }
|
|
640
684
|
return { attachable: true, owners: [], reason: null }
|
|
641
685
|
}
|
|
642
686
|
if (provider !== 'claude' && provider !== 'codex') {
|
|
@@ -34,7 +34,7 @@ const DELIVER_TIMEOUT_MS = 15_000
|
|
|
34
34
|
|
|
35
35
|
interface LoopbackReply { status: number; body: Record<string, unknown> }
|
|
36
36
|
|
|
37
|
-
function post(port: number, token: string, path: string, payload: unknown): Promise<LoopbackReply> {
|
|
37
|
+
function post(port: number, token: string, path: string, payload: unknown, extraHeaders: Record<string, string> = {}): Promise<LoopbackReply> {
|
|
38
38
|
return new Promise((resolve, reject) => {
|
|
39
39
|
const data = JSON.stringify(payload)
|
|
40
40
|
const req = request({
|
|
@@ -47,6 +47,7 @@ function post(port: number, token: string, path: string, payload: unknown): Prom
|
|
|
47
47
|
'content-length': Buffer.byteLength(data),
|
|
48
48
|
// The header the rest of COS authenticates with. NOT `Authorization: Bearer`.
|
|
49
49
|
'X-Cos-Token': token,
|
|
50
|
+
...extraHeaders,
|
|
50
51
|
},
|
|
51
52
|
timeout: DELIVER_TIMEOUT_MS,
|
|
52
53
|
}, res => {
|
|
@@ -77,11 +78,14 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
77
78
|
port: number,
|
|
78
79
|
token: string,
|
|
79
80
|
): Promise<{ ok: boolean; reason?: string; serverRetryable?: boolean }> {
|
|
81
|
+
// 6.62.0 /qa (Q3): repeat the parking client's acknowledgement, and only that.
|
|
82
|
+
const noteHeaders: Record<string, string> = turn.continueNoteAck === true ? { 'X-COS-Continue-Note': '1' } : {}
|
|
80
83
|
try {
|
|
81
84
|
const attach = await post(
|
|
82
85
|
port, token,
|
|
83
86
|
`/api/agent-sessions/${encodeURIComponent(turn.provider)}/${encodeURIComponent(turn.threadId)}/attach`,
|
|
84
87
|
{ cosSessionId: turn.cosSessionId },
|
|
88
|
+
noteHeaders,
|
|
85
89
|
)
|
|
86
90
|
if (attach.status !== 200 && attach.status !== 201) {
|
|
87
91
|
// THE KEY IS `reason`, NOT `error`. `refuseAttach` emits
|
|
@@ -117,6 +121,7 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
117
121
|
targetKey: targetKey(turn.provider, turn.threadId),
|
|
118
122
|
...(boundTo ? { boundTo } : {}),
|
|
119
123
|
},
|
|
124
|
+
noteHeaders,
|
|
120
125
|
)
|
|
121
126
|
if (sent.status === 202 || sent.status === 200) return { ok: true }
|
|
122
127
|
// Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
|
|
@@ -87,6 +87,12 @@ export interface QueuedThreadTurn {
|
|
|
87
87
|
/** The SERVER's reason for a terminal outcome. Never the client's wording. */
|
|
88
88
|
reason?: string
|
|
89
89
|
settledAt?: number
|
|
90
|
+
/**
|
|
91
|
+
* 6.62.0 /qa (Q3): the client that parked this turn sent `X-COS-Continue-Note: 1` (it shows
|
|
92
|
+
* `continue_note`). The drain repeats the header, so a Cursor CLI chat runs Run Everything
|
|
93
|
+
* only for a client that said it would show it. Absent: the 6.61 Ask posture.
|
|
94
|
+
*/
|
|
95
|
+
continueNoteAck?: true
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/**
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The current-request binding for provider CLI spawns (P0 from the 2026-09-27 memory audit).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The COS UserPromptSubmit hook (a Python script in the COS repo, run by
|
|
4
|
+
// the Claude Code and Codex CLIs) builds its memory query from the prompt text the CLI hands
|
|
5
|
+
// it. On a glasses turn that text is not the user's question: the Codex and Cursor bridges
|
|
6
|
+
// send one concatenated "SYSTEM INSTRUCTIONS ... USER REQUEST ..." prompt, and the Claude
|
|
7
|
+
// bridge still wraps the question in image preambles and attachment JSON. The hook then
|
|
8
|
+
// embedded display rules, the calendar and standing tasks, and injected unrelated memories.
|
|
9
|
+
//
|
|
10
|
+
// THE CONTRACT (04_MVP_BUILD_SPEC.md, "Current-request contract", shared with the hook).
|
|
11
|
+
// Per CLI spawn with a genuine user query, write
|
|
12
|
+
//
|
|
13
|
+
// <data dir>/turn-requests/<turnId or uuid>.json directory 0700, file 0600
|
|
14
|
+
// {"schema_version":1,"turn_id":"...","current_user_text":"...",
|
|
15
|
+
// "full_prompt_sha256":"...","created_at":"ISO"}
|
|
16
|
+
//
|
|
17
|
+
// and pass its absolute path to the child as COS_TURN_REQUEST_FILE. The hook uses
|
|
18
|
+
// `current_user_text` only when sha256(hook_prompt.strip()) equals `full_prompt_sha256` and
|
|
19
|
+
// the stripped text is a substring of the hook prompt; otherwise it uses the prompt exactly
|
|
20
|
+
// as before. So a file that does not bind is harmless, and nothing here may fail a turn.
|
|
21
|
+
//
|
|
22
|
+
// THE HASH IS PYTHON'S strip(), NOT JS trim(). The two whitespace sets differ: Python also
|
|
23
|
+
// strips U+001C..U+001F and U+0085, and JS also strips U+FEFF. A prompt ending in one of
|
|
24
|
+
// those would never bind if the server hashed `prompt.trim()`. `stripLikePython` uses the
|
|
25
|
+
// exact `str.isspace()` set (checked against python3 over every code point, 2026-09-29).
|
|
26
|
+
//
|
|
27
|
+
// PRIVACY. The user's text lives only in this private file, never in argv or the child env
|
|
28
|
+
// (argv is world-readable through `ps`; env is readable by every descendant). The file is
|
|
29
|
+
// deleted when the child exits, and anything older than an hour is swept.
|
|
30
|
+
|
|
31
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
32
|
+
import {
|
|
33
|
+
closeSync,
|
|
34
|
+
constants as fsConstants,
|
|
35
|
+
fchmodSync,
|
|
36
|
+
lstatSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
chmodSync,
|
|
39
|
+
openSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
unlinkSync,
|
|
42
|
+
writeSync,
|
|
43
|
+
} from 'node:fs'
|
|
44
|
+
import { join } from 'node:path'
|
|
45
|
+
import { dataPath } from './data-dir.js'
|
|
46
|
+
|
|
47
|
+
/** The child env key that carries the file path. The hook reads exactly this name. */
|
|
48
|
+
export const TURN_REQUEST_ENV = 'COS_TURN_REQUEST_FILE'
|
|
49
|
+
export const TURN_REQUEST_SCHEMA_VERSION = 1
|
|
50
|
+
/** Files older than this are swept. No provider run outlives it: the longest wall cap is 20 min. */
|
|
51
|
+
export const TURN_REQUEST_STALE_MS = 60 * 60_000
|
|
52
|
+
/** The hook ignores a file of 64 KB or more, so one that large is never written. */
|
|
53
|
+
export const TURN_REQUEST_MAX_BYTES = 64 * 1024
|
|
54
|
+
const SWEEP_INTERVAL_MS = 10 * 60_000
|
|
55
|
+
const DIR_NAME = 'turn-requests'
|
|
56
|
+
/** A turn id is used as a file name only when it cannot escape or collide with the directory. */
|
|
57
|
+
const SAFE_TURN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
58
|
+
|
|
59
|
+
/** Exactly the code points for which Python's `str.isspace()` is true. */
|
|
60
|
+
const PY_SPACE = '\\u0009-\\u000d\\u001c-\\u0020\\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
|
|
61
|
+
const PY_STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'gu')
|
|
62
|
+
|
|
63
|
+
/** Python's `str.strip()` with no arguments. */
|
|
64
|
+
export function stripLikePython(text: string): string {
|
|
65
|
+
return text.replace(PY_STRIP, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lowercase hex sha256 of the prompt as the hook will see it: UTF-8, Python-stripped. */
|
|
69
|
+
export function turnPromptSha256(prompt: string): string {
|
|
70
|
+
return createHash('sha256').update(stripLikePython(prompt), 'utf8').digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function turnRequestDir(): string {
|
|
74
|
+
return dataPath(DIR_NAME)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TurnRequestBinding {
|
|
78
|
+
/** Absolute path of the private file. */
|
|
79
|
+
readonly path: string
|
|
80
|
+
readonly turnId: string
|
|
81
|
+
/** Delete the file. Idempotent and never throws. */
|
|
82
|
+
release(): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let lastSweepAt = 0
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delete turn-request files older than `TURN_REQUEST_STALE_MS`. Runs at server startup and
|
|
89
|
+
* again from `createTurnRequestBinding` at most every ten minutes, so a file whose child
|
|
90
|
+
* never reported an exit (or a crash between write and spawn) cannot outlive an hour and a
|
|
91
|
+
* turn. Returns how many files it removed. Never throws.
|
|
92
|
+
*/
|
|
93
|
+
export function sweepStaleTurnRequests(nowMs: number = Date.now()): number {
|
|
94
|
+
lastSweepAt = nowMs
|
|
95
|
+
const dir = turnRequestDir()
|
|
96
|
+
let names: string[]
|
|
97
|
+
try {
|
|
98
|
+
names = readdirSync(dir)
|
|
99
|
+
} catch {
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
let removed = 0
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (!name.endsWith('.json')) continue
|
|
105
|
+
const file = join(dir, name)
|
|
106
|
+
try {
|
|
107
|
+
const st = lstatSync(file)
|
|
108
|
+
if (!st.isFile() && !st.isSymbolicLink()) continue
|
|
109
|
+
if (nowMs - st.mtimeMs <= TURN_REQUEST_STALE_MS) continue
|
|
110
|
+
unlinkSync(file)
|
|
111
|
+
removed += 1
|
|
112
|
+
} catch { /* raced with a release, or unreadable: leave it */ }
|
|
113
|
+
}
|
|
114
|
+
return removed
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Ensure the private directory: a real directory (never a symlink), mode 0700. */
|
|
118
|
+
function ensurePrivateDir(dir: string): void {
|
|
119
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
120
|
+
const st = lstatSync(dir)
|
|
121
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('turn-requests is not a directory')
|
|
122
|
+
if ((st.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create `path` exclusively with mode 0600 and write `body`. O_EXCL refuses an existing path
|
|
127
|
+
* of any kind, a planted symlink or FIFO included (POSIX: with O_CREAT|O_EXCL a symlink is
|
|
128
|
+
* never followed), so nothing is ever written through a link or over another turn's file.
|
|
129
|
+
* O_NOFOLLOW and O_NONBLOCK repeat that on the open line itself, as the server's hazard
|
|
130
|
+
* invariants require of every synchronous open (hazard-invariants.test.ts).
|
|
131
|
+
*/
|
|
132
|
+
function writePrivateFile(path: string, body: string): void {
|
|
133
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, 0o600)
|
|
134
|
+
let ok = false
|
|
135
|
+
try {
|
|
136
|
+
// The umask can only remove bits, but set it explicitly so the contract does not rest on it.
|
|
137
|
+
fchmodSync(fd, 0o600)
|
|
138
|
+
const bytes = Buffer.from(body, 'utf8')
|
|
139
|
+
let written = 0
|
|
140
|
+
while (written < bytes.length) written += writeSync(fd, bytes, written, bytes.length - written)
|
|
141
|
+
ok = true
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd)
|
|
144
|
+
if (!ok) {
|
|
145
|
+
try { unlinkSync(path) } catch { /* best effort */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Write the turn-request file for ONE provider spawn, or return null.
|
|
152
|
+
*
|
|
153
|
+
* `userText` is the raw user query as the bridge received it, before any header, context,
|
|
154
|
+
* image preamble or attachment block. `prompt` is the exact text the CLI will receive as the
|
|
155
|
+
* user message (stdin), byte for byte.
|
|
156
|
+
*
|
|
157
|
+
* Null (and no file) when there is no genuine user query (empty or whitespace only, as for a
|
|
158
|
+
* pre-warm or an image with no words), when the text is not inside the prompt (the hook
|
|
159
|
+
* could never bind it), when the file would reach the hook's 64 KB limit, or on any
|
|
160
|
+
* filesystem failure. The binding is an optimisation for memory retrieval: it must never
|
|
161
|
+
* fail or delay the turn, so every failure is logged once and swallowed.
|
|
162
|
+
*/
|
|
163
|
+
export function createTurnRequestBinding(input: {
|
|
164
|
+
userText: string
|
|
165
|
+
prompt: string
|
|
166
|
+
turnId?: string
|
|
167
|
+
now?: () => Date
|
|
168
|
+
}): TurnRequestBinding | null {
|
|
169
|
+
const userText = typeof input.userText === 'string' ? input.userText : ''
|
|
170
|
+
const stripped = stripLikePython(userText)
|
|
171
|
+
if (!stripped) return null
|
|
172
|
+
if (typeof input.prompt !== 'string' || !input.prompt.includes(stripped)) return null
|
|
173
|
+
|
|
174
|
+
const now = input.now?.() ?? new Date()
|
|
175
|
+
const turnId = typeof input.turnId === 'string' && SAFE_TURN_ID.test(input.turnId)
|
|
176
|
+
? input.turnId
|
|
177
|
+
: randomUUID()
|
|
178
|
+
const record = {
|
|
179
|
+
schema_version: TURN_REQUEST_SCHEMA_VERSION,
|
|
180
|
+
turn_id: turnId,
|
|
181
|
+
current_user_text: userText,
|
|
182
|
+
full_prompt_sha256: turnPromptSha256(input.prompt),
|
|
183
|
+
created_at: now.toISOString(),
|
|
184
|
+
}
|
|
185
|
+
const body = JSON.stringify(record)
|
|
186
|
+
if (Buffer.byteLength(body, 'utf8') >= TURN_REQUEST_MAX_BYTES) return null
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const dir = turnRequestDir()
|
|
190
|
+
ensurePrivateDir(dir)
|
|
191
|
+
if (lastSweepAt === 0 || now.getTime() - lastSweepAt >= SWEEP_INTERVAL_MS) {
|
|
192
|
+
sweepStaleTurnRequests(now.getTime())
|
|
193
|
+
}
|
|
194
|
+
let path = join(dir, `${turnId}.json`)
|
|
195
|
+
try {
|
|
196
|
+
writePrivateFile(path, body)
|
|
197
|
+
} catch (error: any) {
|
|
198
|
+
// A retried turn can reuse its id while the earlier child still holds its file.
|
|
199
|
+
// Never overwrite it: that child's exit would delete this one's file.
|
|
200
|
+
if (error?.code !== 'EEXIST') throw error
|
|
201
|
+
path = join(dir, `${randomUUID()}.json`)
|
|
202
|
+
writePrivateFile(path, body)
|
|
203
|
+
}
|
|
204
|
+
let released = false
|
|
205
|
+
return {
|
|
206
|
+
path,
|
|
207
|
+
turnId,
|
|
208
|
+
release() {
|
|
209
|
+
if (released) return
|
|
210
|
+
released = true
|
|
211
|
+
try { unlinkSync(path) } catch { /* already gone */ }
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error('[turn-request] binding unavailable:', error instanceof Error ? error.message : error)
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The minimal child surface this needs; a real ChildProcess satisfies it. */
|
|
221
|
+
interface ExitObservable {
|
|
222
|
+
readonly pid?: number
|
|
223
|
+
once(event: 'exit' | 'close', listener: (...args: any[]) => void): unknown
|
|
224
|
+
once(event: 'error', listener: (error: Error) => void): unknown
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Delete the binding's file when the child is gone, on every path: a normal exit, a kill
|
|
229
|
+
* (timeout, abort, lost ownership), and a spawn failure. Node reports a spawn failure as
|
|
230
|
+
* 'error' with no pid, then 'close'; an exited child always reports 'exit' and 'close'. An
|
|
231
|
+
* 'error' on a child that DID start (a failed kill) is not an exit, so it keeps the file.
|
|
232
|
+
*/
|
|
233
|
+
export function releaseTurnRequestOnExit(child: ExitObservable, binding: TurnRequestBinding | null): void {
|
|
234
|
+
if (!binding) return
|
|
235
|
+
child.once('exit', () => binding.release())
|
|
236
|
+
child.once('close', () => binding.release())
|
|
237
|
+
child.once('error', () => {
|
|
238
|
+
if (child.pid === undefined) binding.release()
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Spawn through `start` and tie the binding's file to the child's lifetime. A synchronous
|
|
244
|
+
* spawn throw deletes the file before rethrowing, so no path can leave it behind.
|
|
245
|
+
*/
|
|
246
|
+
export function spawnWithTurnRequest<T extends ExitObservable>(
|
|
247
|
+
binding: TurnRequestBinding | null,
|
|
248
|
+
start: () => T,
|
|
249
|
+
): T {
|
|
250
|
+
let child: T
|
|
251
|
+
try {
|
|
252
|
+
child = start()
|
|
253
|
+
} catch (error) {
|
|
254
|
+
binding?.release()
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
257
|
+
releaseTurnRequestOnExit(child, binding)
|
|
258
|
+
return child
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Point the child env at this spawn's file, and never at an inherited one. A server started
|
|
263
|
+
* from inside an agent session could carry the variable, and a stale path must not reach a
|
|
264
|
+
* hook even though the hash check would reject it.
|
|
265
|
+
*/
|
|
266
|
+
export function applyTurnRequestEnv(env: NodeJS.ProcessEnv, binding: TurnRequestBinding | null): void {
|
|
267
|
+
delete env[TURN_REQUEST_ENV]
|
|
268
|
+
if (binding) env[TURN_REQUEST_ENV] = binding.path
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Test seam: forget the sweep clock so a test can observe the first-use sweep. */
|
|
272
|
+
export function resetTurnRequestSweepForTests(): void {
|
|
273
|
+
lastSweepAt = 0
|
|
274
|
+
}
|