@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
|
@@ -25,8 +25,8 @@ import { observeWork, type WorkObservation } from './session-work-observation.js
|
|
|
25
25
|
// 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
|
|
26
26
|
// an ended signal with an alive registry record is a child that ended, not the tab.
|
|
27
27
|
|
|
28
|
-
import type { HookEnvelope, HookEventName } from './session-hook-events.js'
|
|
29
|
-
import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
|
|
28
|
+
import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
|
|
29
|
+
import { AGENT_THOUGHT_MAX, clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
|
|
30
30
|
import { isKeepWarmSessionTitle } from './agent-session-store.js'
|
|
31
31
|
|
|
32
32
|
export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
|
|
@@ -51,6 +51,21 @@ export interface FailureSignal {
|
|
|
51
51
|
export interface SessionSignal {
|
|
52
52
|
workObservation?: WorkObservation
|
|
53
53
|
observationOnly?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* 6.62.0: the engine whose hooks these are (`inferHookProvider`). Absent on a record built
|
|
56
|
+
* from envelopes that never carried one (an older ledger row), which is read as Claude.
|
|
57
|
+
*/
|
|
58
|
+
provider?: HookProvider
|
|
59
|
+
/**
|
|
60
|
+
* 6.62.0: the newest agent thought the Cursor observer reported, capped at
|
|
61
|
+
* AGENT_THOUGHT_MAX. MEMORY ONLY: the ledger keeps no thought text, so a restart forgets it.
|
|
62
|
+
*/
|
|
63
|
+
lastThought?: { text: string; at: number }
|
|
64
|
+
/**
|
|
65
|
+
* 6.62.0 (QA W2): the newest phase event (never an observer one) whose envelope the hook
|
|
66
|
+
* script stamped with its provider: proof a 6.62 script is running this session's hooks.
|
|
67
|
+
*/
|
|
68
|
+
stampedAt?: number
|
|
54
69
|
sessionId: string
|
|
55
70
|
firstSeenAt: number
|
|
56
71
|
lastEventAt: number
|
|
@@ -107,6 +122,7 @@ function toolFacts(p: Record<string, unknown>): { name: string; target: string;
|
|
|
107
122
|
|
|
108
123
|
function fresh(env: HookEnvelope): SessionSignal {
|
|
109
124
|
return {
|
|
125
|
+
...(env.provider ? { provider: env.provider } : {}),
|
|
110
126
|
sessionId: env.sessionId,
|
|
111
127
|
firstSeenAt: env.ts,
|
|
112
128
|
lastEventAt: env.ts,
|
|
@@ -136,6 +152,25 @@ function fresh(env: HookEnvelope): SessionSignal {
|
|
|
136
152
|
|
|
137
153
|
const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
|
|
138
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Observer evidence: never a phase, never a listener. 6.62.0: an AgentThought is display only
|
|
157
|
+
* whatever its payload says, so a thought can never open, close or reopen a turn.
|
|
158
|
+
*/
|
|
159
|
+
export function isDisplayOnly(env: HookEnvelope): boolean {
|
|
160
|
+
return env.payload.display_only === true || env.event === 'AgentThought'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 6.62.0: an AgentThought with text becomes the record's `lastThought`, unless an older one
|
|
165
|
+
* arrived late. A replayed row carries no text (the ledger drops it) and changes nothing.
|
|
166
|
+
*/
|
|
167
|
+
function thoughtOf(base: SessionSignal, env: HookEnvelope): Pick<SessionSignal, 'lastThought'> {
|
|
168
|
+
if (env.event !== 'AgentThought') return {}
|
|
169
|
+
const text = clipText(env.payload.text, AGENT_THOUGHT_MAX)
|
|
170
|
+
if (!text || (base.lastThought && base.lastThought.at > env.ts)) return {}
|
|
171
|
+
return { lastThought: { text, at: env.ts } }
|
|
172
|
+
}
|
|
173
|
+
|
|
139
174
|
/**
|
|
140
175
|
* 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
|
|
141
176
|
* evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
|
|
@@ -181,10 +216,12 @@ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName:
|
|
|
181
216
|
*/
|
|
182
217
|
export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
|
|
183
218
|
const base = prev ? { ...prev } : fresh(env)
|
|
219
|
+
if (env.provider) base.provider = env.provider
|
|
184
220
|
const p = env.payload
|
|
185
|
-
if (
|
|
221
|
+
if (isDisplayOnly(env)) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env), ...thoughtOf(base, env) }
|
|
186
222
|
const common = {
|
|
187
223
|
observationOnly: false,
|
|
224
|
+
...(env.stamped ? { stampedAt: Math.max(base.stampedAt ?? 0, env.ts) } : {}),
|
|
188
225
|
workObservation: observeWork(base.workObservation, env),
|
|
189
226
|
cwd: str(p.cwd) ?? base.cwd,
|
|
190
227
|
transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
|
|
@@ -212,6 +249,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
212
249
|
const prompt = clipText(p.prompt)
|
|
213
250
|
return {
|
|
214
251
|
...next,
|
|
252
|
+
// 6.62.0: Codex and Cursor have no registry to say a session lives on after its
|
|
253
|
+
// SessionEnd (a composer the hooks saw end and then reopened; a Codex thread resumed
|
|
254
|
+
// in the app without a SessionStart). A prompt after the end is a new life. Claude's
|
|
255
|
+
// own resume fires SessionStart, so its rows keep the 6.61 rule.
|
|
256
|
+
...(next.provider === 'codex' || next.provider === 'cursor' ? { ended: null } : {}),
|
|
215
257
|
turnOpen: true,
|
|
216
258
|
turnStartedAt: env.ts,
|
|
217
259
|
promptId: str(p.prompt_id),
|
|
@@ -280,6 +322,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
280
322
|
waiting: null,
|
|
281
323
|
lastReply: clipText(p.last_assistant_message) || next.lastReply,
|
|
282
324
|
}
|
|
325
|
+
// 6.62.0: Codex's Interrupt (Esc) ends the turn with no Stop and no reply.
|
|
326
|
+
case 'Interrupt':
|
|
327
|
+
return { ...next, turnOpen: false, stopAt: env.ts, waiting: null }
|
|
328
|
+
case 'AgentThought': // display only (`isDisplayOnly`); never reaches here
|
|
329
|
+
return next
|
|
283
330
|
case 'StopFailure':
|
|
284
331
|
return {
|
|
285
332
|
...next,
|
|
@@ -303,6 +350,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
|
|
|
303
350
|
}
|
|
304
351
|
|
|
305
352
|
export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
|
|
353
|
+
/** 6.62.0: told of each new agent thought (display only; the drain, permission and halt listeners never are). */
|
|
354
|
+
export type ThoughtListener = (signal: SessionSignal, thought: { text: string; at: number }) => void
|
|
306
355
|
|
|
307
356
|
/** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
|
|
308
357
|
export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
|
|
@@ -312,6 +361,7 @@ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
|
|
|
312
361
|
export class SessionSignalStore {
|
|
313
362
|
private readonly signals = new Map<string, SessionSignal>()
|
|
314
363
|
private readonly listeners = new Set<SignalListener>()
|
|
364
|
+
private readonly thoughtListeners = new Set<ThoughtListener>()
|
|
315
365
|
private readonly ctx: ReducerContext
|
|
316
366
|
|
|
317
367
|
constructor(ctx: ReducerContext) {
|
|
@@ -319,9 +369,21 @@ export class SessionSignalStore {
|
|
|
319
369
|
}
|
|
320
370
|
|
|
321
371
|
apply(env: HookEnvelope, child?: boolean): SessionSignal {
|
|
322
|
-
const
|
|
372
|
+
const prev = this.signals.get(env.sessionId)
|
|
373
|
+
const next = applyHookEvent(prev, env, this.ctx, child)
|
|
323
374
|
this.signals.set(env.sessionId, next)
|
|
324
|
-
if (env
|
|
375
|
+
if (isDisplayOnly(env)) {
|
|
376
|
+
// No drain, permission or halt listeners. A NEW thought reaches the live feed only.
|
|
377
|
+
const thought = next.lastThought
|
|
378
|
+
if (env.event === 'AgentThought' && thought && thought !== prev?.lastThought) {
|
|
379
|
+
for (const listener of this.thoughtListeners) {
|
|
380
|
+
try { listener(next, thought) } catch (error) {
|
|
381
|
+
console.error(`[session-signals] thought listener failed: ${error instanceof Error ? error.message : error}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return next
|
|
386
|
+
}
|
|
325
387
|
const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
|
|
326
388
|
for (const listener of this.listeners) {
|
|
327
389
|
try { listener(next, env, isChild) } catch (error) {
|
|
@@ -392,6 +454,12 @@ export class SessionSignalStore {
|
|
|
392
454
|
return () => { this.listeners.delete(listener) }
|
|
393
455
|
}
|
|
394
456
|
|
|
457
|
+
/** 6.62.0: new agent thoughts, for the live feed. Display only. */
|
|
458
|
+
subscribeThoughts(listener: ThoughtListener): () => void {
|
|
459
|
+
this.thoughtListeners.add(listener)
|
|
460
|
+
return () => { this.thoughtListeners.delete(listener) }
|
|
461
|
+
}
|
|
462
|
+
|
|
395
463
|
/** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
|
|
396
464
|
prune(nowMs = Date.now()): number {
|
|
397
465
|
let dropped = 0
|
|
@@ -426,5 +494,6 @@ export class SessionSignalStore {
|
|
|
426
494
|
__resetForTests(): void {
|
|
427
495
|
this.signals.clear()
|
|
428
496
|
this.listeners.clear()
|
|
497
|
+
this.thoughtListeners.clear()
|
|
429
498
|
}
|
|
430
499
|
}
|
|
@@ -565,6 +565,37 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
|
|
|
565
565
|
return [{ kind: 'prompt', text: flat }]
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Cursor (6.62.0, plan 1.5): the user's words are the inside of `<user_query>`; the rest of a
|
|
570
|
+
* Cursor user row is scaffolding (attached files, rules). Until 6.62.0 the whole row was
|
|
571
|
+
* dropped, so a Cursor feed never showed what it was working on. Everything else is the
|
|
572
|
+
* Claude-shaped content the two share.
|
|
573
|
+
*/
|
|
574
|
+
function draftsFromCursorRecord(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
575
|
+
if (record.role === 'user' && record.type === undefined) {
|
|
576
|
+
const message = asRecord(record.message)
|
|
577
|
+
if (!message) return []
|
|
578
|
+
const blocks = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
|
|
579
|
+
if (blocks.some(b => asRecord(b)?.type === 'tool_result')) return []
|
|
580
|
+
const text = blocks.map(b => asRecord(b)).filter(b => b && (b.type === 'text' || b.type === undefined) && typeof b.text === 'string').map(b => b!.text as string).join(' ')
|
|
581
|
+
const start = text.indexOf('<user_query>')
|
|
582
|
+
const end = text.indexOf('</user_query>')
|
|
583
|
+
const query = start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
|
|
584
|
+
return promptDrafts({ content: [{ type: 'text', text: query }] })
|
|
585
|
+
}
|
|
586
|
+
return draftsFromClaudeRecord(record)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Cursor (6.62.0, plan 1.7): the observer's newest agent thought, as the reasoning line. The
|
|
591
|
+
* same shape Codex's reasoning headline rides (`CodexReasoningStatus`): an extra field on a
|
|
592
|
+
* `status: working` draft, one line, as long as a target.
|
|
593
|
+
*/
|
|
594
|
+
export function cursorThoughtStatus(text: unknown): CodexReasoningStatus | null {
|
|
595
|
+
const reasoning = typeof text === 'string' ? oneLine(text, TARGET_MAX_CHARS) : ''
|
|
596
|
+
return reasoning ? { kind: 'status', state: 'working', reasoning } : null
|
|
597
|
+
}
|
|
598
|
+
|
|
568
599
|
function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
569
600
|
const type = typeof record.type === 'string' ? record.type : ''
|
|
570
601
|
|
|
@@ -1262,7 +1293,7 @@ export function draftsFromRecord(
|
|
|
1262
1293
|
const obj = asRecord(record)
|
|
1263
1294
|
if (!obj) return []
|
|
1264
1295
|
try {
|
|
1265
|
-
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1296
|
+
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : provider === 'cursor' ? draftsFromCursorRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1266
1297
|
return stampPromptTurnStart(drafts, obj)
|
|
1267
1298
|
} catch {
|
|
1268
1299
|
// A malformed record costs one line of the trail. It must never cost the stream.
|
|
@@ -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
|
/**
|