@gotcos/glasses-server 6.61.7 → 6.63.0
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 +39 -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 +141 -17
- 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-job-ledger.ts +194 -0
- package/server/lib/fork-thread.ts +169 -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 +349 -26
- 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
|
@@ -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
|
+
}
|