@gotcos/glasses-server 6.99.20 → 6.99.29
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 +257 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +48 -5
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +26 -5
- package/server/web-control/app.js +1351 -177
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +31 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +264 -48
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +419 -64
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +136 -6
- package/server/web-control/panes/today-actions.css +167 -0
- package/server/web-control/panes/today-actions.js +1303 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
import type { FenceRecord } from '../lib/thread-fence-store.js'
|
|
99
99
|
import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
|
|
100
100
|
import { Router, type Request, type Response } from 'express'
|
|
101
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
101
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
102
102
|
import {
|
|
103
103
|
threadOccupancy,
|
|
104
104
|
type Occupancy,
|
|
@@ -131,7 +131,9 @@ import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
|
131
131
|
import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
|
|
132
132
|
import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
|
|
133
133
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
134
|
+
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, QUEUED_TURN_TAMPERED_COPY, sealMatches, TURN_CARRY_HEADER, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict, type QueueSeal } from '../lib/g2-turn-provenance.js'
|
|
134
135
|
import { KEYED_SESSION_REFUSAL } from '../lib/keyed-sessions.js'
|
|
136
|
+
import { associateTurnAttachments, composeTurnPrompt, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
|
|
135
137
|
import {
|
|
136
138
|
CANCEL_QUEUE_HOLD_MS,
|
|
137
139
|
CLIENT_CANCEL_ID_RE,
|
|
@@ -318,6 +320,13 @@ export type AttachedTurnResult =
|
|
|
318
320
|
| { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled' }
|
|
319
321
|
|
|
320
322
|
export interface AgentSessionBindingsDeps {
|
|
323
|
+
/**
|
|
324
|
+
* G2 authority Tier 1, with web origin (6.99.29): where each turn came from. With it, every turn
|
|
325
|
+
* and fork this router hands to a session is ledgered, and a VERIFIED glasses or phone turn
|
|
326
|
+
* carries the one verify footer line (a Control Web turn is recorded, never vouched or footed).
|
|
327
|
+
* Footer-shaped words and reused turn ids are refused. Absent: the 6.99.28 behaviour, byte for byte.
|
|
328
|
+
*/
|
|
329
|
+
provenance?: G2TurnProvenance
|
|
321
330
|
/** Shared fence state. Omit and the router owns a private one, which is correct
|
|
322
331
|
* for tests and wrong for the server -- see the wiring note at its use site. */
|
|
323
332
|
guard?: TargetGuard
|
|
@@ -437,6 +446,11 @@ export interface AgentSessionBindingsDeps {
|
|
|
437
446
|
* and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
|
|
438
447
|
*/
|
|
439
448
|
turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
|
|
449
|
+
/**
|
|
450
|
+
* 11/07 (Control Web attach): the media store a turn's `attachmentIds` resolve against. Absent: the real store
|
|
451
|
+
* (lib/session-turn-attachments.ts). Tests pass a fake. Never read for a turn without files.
|
|
452
|
+
*/
|
|
453
|
+
turnAttachmentStore?: TurnAttachmentStore
|
|
440
454
|
|
|
441
455
|
/**
|
|
442
456
|
* 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
|
|
@@ -2601,10 +2615,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2601
2615
|
if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
|
|
2602
2616
|
return refuseFork('invalid_request')
|
|
2603
2617
|
}
|
|
2604
|
-
const
|
|
2605
|
-
if (typeof
|
|
2618
|
+
const words = body.prompt
|
|
2619
|
+
if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
|
|
2606
2620
|
return refuseFork('invalid_request')
|
|
2607
2621
|
}
|
|
2622
|
+
// 6.99.27: a fork's first turn takes files as a turn does (the message written with them, sent to the copy).
|
|
2623
|
+
const forkFiles = parseTurnAttachmentIds(body.attachmentIds)
|
|
2624
|
+
if (!forkFiles.ok) return refuseFork('invalid_request')
|
|
2608
2625
|
// 6.63.0: optional. With it, the fork answers 202 once every gate has passed and runs
|
|
2609
2626
|
// in the background; without it, the route behaves exactly as before.
|
|
2610
2627
|
const clientForkId = body.clientForkId
|
|
@@ -2628,6 +2645,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2628
2645
|
if (known) return known.source === forkSource ? replayForkJob(known) : refuseFork('invalid_request')
|
|
2629
2646
|
}
|
|
2630
2647
|
|
|
2648
|
+
// After the replay (a known fork sends nothing), before the workspace, the claim and the spawn: where each file is
|
|
2649
|
+
// on this Mac goes under the words. Without files the prompt is the words, untouched. Nothing is kept yet: the
|
|
2650
|
+
// files are associated only once this fork holds its claim (below, QA 6.99.27).
|
|
2651
|
+
const forkComposed = await composeTurnPrompt(words, forkFiles.ids, {
|
|
2652
|
+
maxChars: maxPromptChars,
|
|
2653
|
+
...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
|
|
2654
|
+
})
|
|
2655
|
+
if (!forkComposed.ok) {
|
|
2656
|
+
return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[forkComposed.code], attachmentError: forkComposed.code })
|
|
2657
|
+
}
|
|
2658
|
+
const prompt = forkComposed.prompt
|
|
2659
|
+
// G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
|
|
2660
|
+
// name) carrying anything footer-shaped are refused before anything is spawned.
|
|
2661
|
+
if (deps.provenance && hasFooterShapedLine(prompt)) {
|
|
2662
|
+
return refuseFork('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
|
|
2663
|
+
}
|
|
2664
|
+
// A client fork id this server already holds a turn record for is never reused (a new id
|
|
2665
|
+
// would overwrite that record). A repeat of this same fork was replayed above.
|
|
2666
|
+
if (deps.provenance && typeof clientForkId === 'string' && deps.provenance.holds(clientForkId)) {
|
|
2667
|
+
return refuseFork('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2631
2670
|
const now = readNow()
|
|
2632
2671
|
if (now === null) return refuseFork('fork_failed')
|
|
2633
2672
|
|
|
@@ -2636,7 +2675,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2636
2675
|
let cwd: string | null = null
|
|
2637
2676
|
try {
|
|
2638
2677
|
// 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
|
|
2639
|
-
// store).
|
|
2678
|
+
// store). Before the claim below, whose check and set stay one synchronous call.
|
|
2640
2679
|
cwd = await resolveWorkspace(providerParam, threadIdParam)
|
|
2641
2680
|
} catch (error) {
|
|
2642
2681
|
console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
|
|
@@ -2649,9 +2688,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2649
2688
|
return refuseFork('fork_workspace_unresolvable')
|
|
2650
2689
|
}
|
|
2651
2690
|
|
|
2652
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2691
|
+
// Check and set in ONE synchronous call (awaits above, the workspace read and the
|
|
2692
|
+
// files, come before it; nothing may sit between the check and the set). One fork
|
|
2693
|
+
// per source thread at a time: this route spawns a provider CLI, and without a claim
|
|
2694
|
+
// a client retry loop spawns one child per request with nothing bounding it.
|
|
2655
2695
|
// A DISTINCT key namespace from the turn claim, so a fork can never block a
|
|
2656
2696
|
// continuation or be blocked by one — `targetKey` is length-prefixed and
|
|
2657
2697
|
// therefore unambiguous, and this prefix cannot collide with one.
|
|
@@ -2665,6 +2705,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2665
2705
|
}
|
|
2666
2706
|
claimedForkKey = forkKey
|
|
2667
2707
|
|
|
2708
|
+
// QA 6.99.27 (Skeptic W2): the files are kept with this fork only now that it holds the claim and every gate
|
|
2709
|
+
// has passed, before the job is recorded or anything is spawned. A file that went in the meantime refuses here.
|
|
2710
|
+
if (forkFiles.ids.length > 0) {
|
|
2711
|
+
const kept = await associateTurnAttachments(forkFiles.ids, `session-fork:${typeof clientForkId === 'string' ? clientForkId : mintId()}`, deps.turnAttachmentStore)
|
|
2712
|
+
if (!kept.ok) return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2668
2715
|
if (typeof clientForkId === 'string') {
|
|
2669
2716
|
// Recorded BEFORE the spawn and before the 202: a job the ledger cannot hold would
|
|
2670
2717
|
// have an outcome nobody can read, so it does not start.
|
|
@@ -2686,12 +2733,26 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2686
2733
|
console.log(`[agent-session-bindings] fork accepted provider=${providerParam} background=true`)
|
|
2687
2734
|
}
|
|
2688
2735
|
|
|
2736
|
+
// G2 authority Tier 1: a fork is a turn too, ledgered. Its id is the client's fork id, or one
|
|
2737
|
+
// minted here; the target is the SOURCE thread (the copy's id is not known until it ran). The
|
|
2738
|
+
// hash covers the prompt as composed (words plus any files block). Never vouched (below).
|
|
2739
|
+
let forkPrompt = prompt
|
|
2740
|
+
if (deps.provenance) {
|
|
2741
|
+
// 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
|
|
2742
|
+
// that actually receives the words, and the child has no binding yet. Until a destination
|
|
2743
|
+
// binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
|
|
2744
|
+
const forkVerdict = unvouched(deps.provenance.originOf(req), 'fork_destination_unbound')
|
|
2745
|
+
const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
|
|
2746
|
+
forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
|
|
2747
|
+
deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2689
2750
|
let raw: unknown
|
|
2690
2751
|
try {
|
|
2691
2752
|
raw = await fork({
|
|
2692
2753
|
provider: providerParam,
|
|
2693
2754
|
nativeThreadId: threadIdParam,
|
|
2694
|
-
prompt,
|
|
2755
|
+
prompt: forkPrompt,
|
|
2695
2756
|
cwd,
|
|
2696
2757
|
// Text-only, same as the attached path. A fork runs a real model turn
|
|
2697
2758
|
// against a workspace the user did not hand us explicitly.
|
|
@@ -2973,10 +3034,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2973
3034
|
// an empty one, and must not fall through as a turn with no claims to check.
|
|
2974
3035
|
if (body === null) return refuseTurn('invalid_request')
|
|
2975
3036
|
|
|
2976
|
-
const
|
|
2977
|
-
if (typeof
|
|
3037
|
+
const words = body.prompt
|
|
3038
|
+
if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
|
|
2978
3039
|
return refuseTurn('invalid_request')
|
|
2979
3040
|
}
|
|
3041
|
+
// 11/07: files with the turn, as media-store ids. Absent is the old turn; a bad list refuses it whole.
|
|
3042
|
+
const attachIds = parseTurnAttachmentIds(body.attachmentIds)
|
|
3043
|
+
if (!attachIds.ok) return refuseTurn('invalid_request')
|
|
2980
3044
|
const epoch = body.epoch
|
|
2981
3045
|
if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
|
|
2982
3046
|
const claimedTargetKey = body.targetKey
|
|
@@ -3014,6 +3078,59 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3014
3078
|
return
|
|
3015
3079
|
}
|
|
3016
3080
|
|
|
3081
|
+
// 11/07: the files' paths on this Mac go under the words HERE, after the ledger replay (a replayed turn sends
|
|
3082
|
+
// nothing) and before the receipt (which matches the transcript against what was actually sent). Without files
|
|
3083
|
+
// the prompt is the words, untouched. A file that cannot be read refuses the turn before anything is sent. The
|
|
3084
|
+
// files are kept with the turn only after the claim and the gates (before the pin, below; QA 6.99.27).
|
|
3085
|
+
const composed = await composeTurnPrompt(words, attachIds.ids, {
|
|
3086
|
+
maxChars: maxPromptChars,
|
|
3087
|
+
...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
|
|
3088
|
+
})
|
|
3089
|
+
if (!composed.ok) {
|
|
3090
|
+
return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[composed.code], attachmentError: composed.code })
|
|
3091
|
+
}
|
|
3092
|
+
const prompt = composed.prompt
|
|
3093
|
+
// G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
|
|
3094
|
+
// name) carrying anything footer-shaped are refused here, before any gate, claim or hop.
|
|
3095
|
+
if (deps.provenance && hasFooterShapedLine(prompt)) {
|
|
3096
|
+
return refuseTurn('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// G2 authority Tier 1 (6.99.29 port, with web origin): who said this, and the exact text the
|
|
3100
|
+
// session will see. A turn the queue drains re-enters over loopback with a single-use carry
|
|
3101
|
+
// token naming the origin captured when it was parked (bound to the WORDS it parked, before
|
|
3102
|
+
// any files block); anything else is judged by its own socket, its token and its cookie.
|
|
3103
|
+
// `prompt` stays the unfooted text: the receipts, the turn ledger and the queue evidence
|
|
3104
|
+
// compare against it, and the transcript readers take the footer off before they compare.
|
|
3105
|
+
const provenance = deps.provenance
|
|
3106
|
+
let turnVerdict: OriginVerdict | null = null
|
|
3107
|
+
let turnCarried = false
|
|
3108
|
+
/** The seal a vouched queued turn carries; checked against the files here and the binding below. */
|
|
3109
|
+
let turnSeal: QueueSeal | null = null
|
|
3110
|
+
if (provenance) {
|
|
3111
|
+
// A carry marks the queue's own drain of a parked turn: it carries the parked origin while
|
|
3112
|
+
// one is held (else none, and the hop is judged as the local call it is) and is the one
|
|
3113
|
+
// caller allowed to add to that turn's record.
|
|
3114
|
+
const carried = provenance.takeCarry(req.headers[TURN_CARRY_HEADER], clientTurnId, words)
|
|
3115
|
+
turnCarried = carried !== null
|
|
3116
|
+
turnVerdict = carried?.verdict ?? provenance.originOf(req)
|
|
3117
|
+
turnSeal = carried?.seal ?? null
|
|
3118
|
+
}
|
|
3119
|
+
// 6.99.29 (Codex audit of e4758a6): the queue file holds MUTABLE attachment ids, and the drain
|
|
3120
|
+
// composes them into the text this turn vouches for. A carried seal names the manifest (each id
|
|
3121
|
+
// with its content hash) the origin was given for; files that differ now refuse the turn.
|
|
3122
|
+
if (turnSeal !== null) {
|
|
3123
|
+
const files = attachIds.ids.length === 0 ? { ok: true as const, manifest: [] } : await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
|
|
3124
|
+
if (!files.ok || attachmentsDigest(files.manifest) !== turnSeal.attachments) {
|
|
3125
|
+
console.warn(`[agent-session-bindings] queued turn seal mismatch (files) turnId=${turnId} clientTurnId=${clientTurnId}`)
|
|
3126
|
+
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
/** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
|
|
3130
|
+
const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
|
|
3131
|
+
if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3017
3134
|
// 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
|
|
3018
3135
|
// with its binding and records nothing for a pre-202 `live_unverified`; a crash after
|
|
3019
3136
|
// the pin leaves no record at all. The receipt covers those (target-scoped attempt
|
|
@@ -3074,6 +3191,22 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3074
3191
|
if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
|
|
3075
3192
|
return refuseTurn('binding_unusable')
|
|
3076
3193
|
}
|
|
3194
|
+
// G2 authority Tier 1 (QA 6.99.29): a turn id this server already holds a record for is never
|
|
3195
|
+
// reused for a different turn (it would overwrite that record: a denial of service on a
|
|
3196
|
+
// vouched turn). An identical retry is not reuse, and the queue's own drain (a carry) continues
|
|
3197
|
+
// its turn while that turn is still parked; never over a delivered record.
|
|
3198
|
+
// ...and the seal names the provider and thread it was parked for: a drain into any other
|
|
3199
|
+
// binding is refused, never delivered under the parked origin.
|
|
3200
|
+
if (turnSeal !== null && !sealMatches(turnSeal, { provider: binding.provider, threadId: binding.nativeThreadId, attachments: turnSeal.attachments })) {
|
|
3201
|
+
console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
|
|
3202
|
+
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3203
|
+
}
|
|
3204
|
+
if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
|
|
3205
|
+
return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
3206
|
+
}
|
|
3207
|
+
// Footed HERE, once the binding names the harness the text goes into: the footer's verify
|
|
3208
|
+
// command carries that provider (Codex re-audit of 910eb90). Every delivery below uses this text.
|
|
3209
|
+
const deliveredPrompt = provenance && turnVerdict ? provenance.footed(prompt, clientTurnId, turnVerdict, binding.provider) : prompt
|
|
3077
3210
|
|
|
3078
3211
|
const key = binding.targetKey
|
|
3079
3212
|
const fenced = guard.fencedReason(key)
|
|
@@ -3082,9 +3215,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3082
3215
|
return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
|
|
3083
3216
|
}
|
|
3084
3217
|
|
|
3085
|
-
//
|
|
3086
|
-
//
|
|
3087
|
-
// see a free target and
|
|
3218
|
+
// Check-and-set in ONE synchronous call. Awaits come before it (the files above,
|
|
3219
|
+
// QA 6.99.27) and after it, never between the check and the set: with an await
|
|
3220
|
+
// between them, two turns admitted in the same tick both see a free target and
|
|
3221
|
+
// both deliver.
|
|
3088
3222
|
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
3089
3223
|
claimedKey = key
|
|
3090
3224
|
// 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
|
|
@@ -3137,6 +3271,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3137
3271
|
guard.acknowledgeHead(bindingId, head.digest)
|
|
3138
3272
|
}
|
|
3139
3273
|
|
|
3274
|
+
// QA 6.99.27 (Skeptic W2): the files are kept with this turn only now, with the claim held and every gate
|
|
3275
|
+
// passed, and before the pin and any hop. A turn refused above keeps nothing past the 4 hour staged TTL.
|
|
3276
|
+
if (attachIds.ids.length > 0) {
|
|
3277
|
+
const kept = await associateTurnAttachments(attachIds.ids, `session-turn:${clientTurnId}`, deps.turnAttachmentStore)
|
|
3278
|
+
stage('files')
|
|
3279
|
+
if (!kept.ok) return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3140
3282
|
// A failed pin is FATAL, per the registry's own caller contract: an unpinned
|
|
3141
3283
|
// binding can expire or be detached mid-turn, which defeats the lease.
|
|
3142
3284
|
// 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
|
|
@@ -3184,7 +3326,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3184
3326
|
live = await deps.deliverLiveTurn({
|
|
3185
3327
|
provider: binding.provider,
|
|
3186
3328
|
sessionId: binding.nativeThreadId,
|
|
3187
|
-
prompt,
|
|
3329
|
+
prompt: deliveredPrompt,
|
|
3188
3330
|
verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
|
|
3189
3331
|
clientTurnId,
|
|
3190
3332
|
})
|
|
@@ -3204,6 +3346,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3204
3346
|
const latched = latchedHere()
|
|
3205
3347
|
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
3206
3348
|
console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
|
|
3349
|
+
ledgerTurn('live', binding.provider, binding.nativeThreadId)
|
|
3207
3350
|
respond(200, {
|
|
3208
3351
|
turnId,
|
|
3209
3352
|
outcome: 'completed',
|
|
@@ -3222,6 +3365,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3222
3365
|
// 6.53.3: something MAY be in the session and the person asked for it to stop: arm
|
|
3223
3366
|
// the marker as for a landed turn, and settle this turn as cancelled, never as a
|
|
3224
3367
|
// retryable hold that the queue would send again after the cancel's two minutes.
|
|
3368
|
+
// The text MAY be in the session, footer and all: a record, so a verify finds it.
|
|
3369
|
+
ledgerTurn('live-unverified', binding.provider, binding.nativeThreadId)
|
|
3225
3370
|
const latched = latchedHere()
|
|
3226
3371
|
if (latched) {
|
|
3227
3372
|
settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
@@ -3263,7 +3408,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3263
3408
|
live = await deps.deliverCodexLiveTurn({
|
|
3264
3409
|
provider: binding.provider,
|
|
3265
3410
|
sessionId: binding.nativeThreadId,
|
|
3266
|
-
prompt,
|
|
3411
|
+
prompt: deliveredPrompt,
|
|
3267
3412
|
foreignHolder: true,
|
|
3268
3413
|
})
|
|
3269
3414
|
} catch (error) {
|
|
@@ -3278,6 +3423,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3278
3423
|
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
3279
3424
|
const latched = latchedHere()
|
|
3280
3425
|
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
3426
|
+
ledgerTurn('codex-live', binding.provider, binding.nativeThreadId)
|
|
3281
3427
|
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
3282
3428
|
respond(200, {
|
|
3283
3429
|
turnId,
|
|
@@ -3320,6 +3466,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3320
3466
|
// delivery is: ambiguous, ledgered (a replay answers the same), and fenced until
|
|
3321
3467
|
// a person looks at the Codex app's queue and releases it.
|
|
3322
3468
|
if (live === null || live.reason === 'unverified') {
|
|
3469
|
+
ledgerTurn('codex-live-unverified', binding.provider, binding.nativeThreadId)
|
|
3323
3470
|
guard.fence(key, 'native_target_fenced', {
|
|
3324
3471
|
provider: binding.provider,
|
|
3325
3472
|
headBefore: head.digest,
|
|
@@ -3373,6 +3520,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3373
3520
|
let delivery: Delivery
|
|
3374
3521
|
deliveryAttempted = true
|
|
3375
3522
|
spawnMay = true
|
|
3523
|
+
ledgerTurn('spawn', binding.provider, binding.nativeThreadId)
|
|
3376
3524
|
try {
|
|
3377
3525
|
const result = await deliver({
|
|
3378
3526
|
turnId,
|
|
@@ -3383,7 +3531,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3383
3531
|
workspaceFingerprint: binding.workspaceFingerprint,
|
|
3384
3532
|
sourceFingerprint: binding.sourceFingerprint,
|
|
3385
3533
|
expectedNativeHead: head.raw,
|
|
3386
|
-
prompt,
|
|
3534
|
+
prompt: deliveredPrompt,
|
|
3387
3535
|
abortSignal: cancelController.signal,
|
|
3388
3536
|
// 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
|
|
3389
3537
|
continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
|
|
@@ -83,6 +83,8 @@ import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
|
83
83
|
import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
|
|
84
84
|
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
85
85
|
|
|
86
|
+
import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
|
|
87
|
+
|
|
86
88
|
export const agentSessionsRouter = Router()
|
|
87
89
|
|
|
88
90
|
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
@@ -236,7 +238,7 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
|
|
|
236
238
|
// clears on its own when the work stops. Still a display hint, never a gate.
|
|
237
239
|
// 6.62.0: or the engine's own hooks (or a Codex rollout) say a turn is open and the
|
|
238
240
|
// last event is inside HOOK_ACTIVE_CAP_MS (plan 1.1-1.3).
|
|
239
|
-
running_active: decides ? hint!.active : occ?.activeRecently === true || hint?.active === true,
|
|
241
|
+
running_active: decides || hint?.activeDecides === true ? hint!.active : occ?.activeRecently === true || hint?.active === true,
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -254,14 +256,22 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
|
|
|
254
256
|
* and an Esc with no Stop looks the same, QA W8);
|
|
255
257
|
* Claude and Codex: no hint (occupancy answers)
|
|
256
258
|
* hook waiting running, not active
|
|
259
|
+
* hook or registry idle (Claude, Codex) not active even if the file was just written
|
|
260
|
+
* (a flush after Stop), unless the transcript's
|
|
261
|
+
* newest record is more than
|
|
262
|
+
* TRANSCRIPT_AFTER_IDLE_GRACE_MS past the idle
|
|
263
|
+
* signal: then no hint (occupancy freshness
|
|
264
|
+
* answers, a hookless restarted turn)
|
|
265
|
+
* hook or registry waiting, ended, failed not active, ownership kept (Cursor: decided)
|
|
257
266
|
* Codex rollout turn open inside the cap running, active (no Codex hooks yet)
|
|
258
267
|
* Cursor, any other hook state not running, not active (`decides`)
|
|
259
268
|
*
|
|
260
269
|
* A hook-derived `running` only exists inside the deriver's 30-minute open-turn ceiling, so an
|
|
261
270
|
* open-quiet Cursor row ends there, or at its Stop or SessionEnd.
|
|
262
271
|
*
|
|
263
|
-
* For Claude and Codex
|
|
264
|
-
*
|
|
272
|
+
* For Claude and Codex, authoritative idle/waiting states suppress recent-write activity
|
|
273
|
+
* for display while leaving ownership intact. Other hints add to occupancy. For Cursor,
|
|
274
|
+
* whose list has no occupancy at all, a hook signal decides, and
|
|
265
275
|
* a running Cursor turn is the user's own (an IDE composer or their terminal; COS's own child
|
|
266
276
|
* is kept out of the phase), so it is foreign.
|
|
267
277
|
*/
|
|
@@ -271,15 +281,42 @@ export interface RunningHint {
|
|
|
271
281
|
foreign: boolean
|
|
272
282
|
/** Cursor with hook evidence: this hint IS the answer, occupancy and file time are not read. */
|
|
273
283
|
decides: boolean
|
|
284
|
+
/** Authoritative non-working state suppresses file-write activity without changing occupancy. */
|
|
285
|
+
activeDecides?: boolean
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
|
|
288
|
+
/**
|
|
289
|
+
* A transcript write this long after the idle signal is new work, not the flush that
|
|
290
|
+
* trails a Stop. Claude writes its last records around the Stop hook; a turn that wakes
|
|
291
|
+
* later (a background-task notification restarts a text-only turn, with no prompt and no
|
|
292
|
+
* tool call, so no hook fires until its Stop) writes well past this.
|
|
293
|
+
*/
|
|
294
|
+
export const TRANSCRIPT_AFTER_IDLE_GRACE_MS = 5_000
|
|
295
|
+
|
|
296
|
+
export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number, transcriptAt: number | null = null): RunningHint | null {
|
|
277
297
|
const cursor = provider === 'cursor'
|
|
278
298
|
const quiet: RunningHint | null = cursor ? { running: false, active: false, foreign: false, decides: true } : null
|
|
279
299
|
if (!derived) return null
|
|
300
|
+
// The state deriver already arbitrates fresh work, waits and completed turns.
|
|
301
|
+
// A transcript flush after Stop must not turn an idle/waiting badge back to
|
|
302
|
+
// working. Keep ownership/open-window evidence intact: this is display only.
|
|
303
|
+
if ((derived.state_source === 'hook' || derived.state_source === 'registry') && derived.agent_state !== 'running') {
|
|
304
|
+
// An idle signal OLDER than the transcript's newest real record is not the latest
|
|
305
|
+
// word: a turn restarted with no hook (see TRANSCRIPT_AFTER_IDLE_GRACE_MS) is writing.
|
|
306
|
+
// Fall back to occupancy freshness. Idle only: waiting keeps `running: true` and its
|
|
307
|
+
// question; ended and failed stay settled. Cursor's transcript has no clock.
|
|
308
|
+
if (!cursor && derived.agent_state === 'idle' && transcriptAt !== null) {
|
|
309
|
+
const since = Date.parse(derived.state_since)
|
|
310
|
+
const signalAt = Math.max(Number.isFinite(since) ? since : 0, derived.state_source === 'hook' ? lastEventAt ?? 0 : 0)
|
|
311
|
+
if (transcriptAt > signalAt + TRANSCRIPT_AFTER_IDLE_GRACE_MS) return null
|
|
312
|
+
}
|
|
313
|
+
if (cursor) return derived.agent_state === 'waiting'
|
|
314
|
+
? { running: true, active: false, foreign: true, decides: true }
|
|
315
|
+
: quiet
|
|
316
|
+
return { running: derived.agent_state === 'waiting', active: false, foreign: false, decides: false, activeDecides: true }
|
|
317
|
+
}
|
|
280
318
|
const inCap = lastEventAt !== null && now - lastEventAt <= HOOK_ACTIVE_CAP_MS && lastEventAt <= now + 5_000
|
|
281
319
|
if (derived.state_source === 'hook') {
|
|
282
|
-
if (derived.agent_state === 'waiting') return { running: true, active: false, foreign: cursor, decides: cursor }
|
|
283
320
|
if (derived.agent_state === 'running' && inCap) return { running: true, active: true, foreign: cursor, decides: cursor }
|
|
284
321
|
if (derived.agent_state === 'running' && cursor) return { running: true, active: false, foreign: true, decides: true }
|
|
285
322
|
return quiet
|
|
@@ -535,7 +572,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
535
572
|
const derived = derivedById.get(row.session_id)
|
|
536
573
|
const signal = signalFor(row.session_id)
|
|
537
574
|
const facts = rolloutById.get(row.session_id) ?? null
|
|
538
|
-
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now)
|
|
575
|
+
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
|
|
539
576
|
return {
|
|
540
577
|
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
541
578
|
...cursorActivityFields(row.provider, signal),
|
|
@@ -604,6 +641,37 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
604
641
|
}
|
|
605
642
|
})
|
|
606
643
|
|
|
644
|
+
// Authenticated by index.ts's shared /api gate. Neither the request nor the response
|
|
645
|
+
// carries a filesystem path; the resolver reauthorizes exact transcript membership.
|
|
646
|
+
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:imageId/content', async (req, res) => {
|
|
647
|
+
res.set('Cache-Control', 'private, no-store')
|
|
648
|
+
res.set('X-Content-Type-Options', 'nosniff')
|
|
649
|
+
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
650
|
+
if (!provider || (req.query.variant !== undefined && req.query.variant !== 'thumb' && req.query.variant !== 'phone')) {
|
|
651
|
+
res.status(404).json({ error: 'image_not_found' }); return
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const content = await agentSessionOutputImages.content(provider, String(req.params.sessionId), String(req.params.imageId), req.query.variant === 'thumb' ? 'thumb' : 'phone')
|
|
655
|
+
if (content.status !== 'ok') {
|
|
656
|
+
res.status(content.status === 'not_found' ? 404 : 503).json({ error: content.status === 'not_found' ? 'image_not_found' : 'image_unavailable' }); return
|
|
657
|
+
}
|
|
658
|
+
res.type('image/jpeg')
|
|
659
|
+
// `send` defaults to `dotfiles: 'ignore'` and applies it to the WHOLE absolute path
|
|
660
|
+
// when no `root` is given, so a media root under `~/.cos-glasses/data` 404'd on
|
|
661
|
+
// every default install (see the header of lib/send-audio.ts). 'allow' is safe: the
|
|
662
|
+
// dot component is our data home, and the resolver above returned an exact,
|
|
663
|
+
// containment-checked path with no caller-supplied segments.
|
|
664
|
+
res.sendFile(content.path, { dotfiles: 'allow' }, err => {
|
|
665
|
+
if (!err) return
|
|
666
|
+
if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
|
|
667
|
+
else res.destroy()
|
|
668
|
+
})
|
|
669
|
+
} catch {
|
|
670
|
+
console.warn('[agent-sessions] output image unavailable')
|
|
671
|
+
res.status(503).json({ error: 'image_unavailable' })
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
|
|
607
675
|
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res) => {
|
|
608
676
|
res.set('Cache-Control', 'private, no-store')
|
|
609
677
|
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
@@ -647,7 +715,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
647
715
|
let recentTurns: RecentTurnsRead | null = null
|
|
648
716
|
if (turnsWanted !== null) {
|
|
649
717
|
try {
|
|
650
|
-
|
|
718
|
+
const images = await agentSessionOutputImages.context(provider, sessionId, found)
|
|
719
|
+
recentTurns = await readRecentSessionTurns(provider, found, turnsWanted, undefined, images.project)
|
|
651
720
|
} catch (error) {
|
|
652
721
|
console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
|
|
653
722
|
}
|
|
@@ -684,7 +753,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
684
753
|
derived = deriveCodexRow(parsed.session_id, running.occupied.has(parsed.session_id), rollout, now, false)
|
|
685
754
|
}
|
|
686
755
|
const signal = signalFor(parsed.session_id)
|
|
687
|
-
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now)
|
|
756
|
+
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now, activityClockMs(st.mtimeMs, activity))
|
|
688
757
|
res.json({
|
|
689
758
|
...withRunning({ session_id: parsed.session_id }, running, hint),
|
|
690
759
|
...derivedRowFields(derived),
|
|
@@ -32,6 +32,17 @@ export const CLIENT_INSTANCE_MAX_SEEN = 64
|
|
|
32
32
|
|
|
33
33
|
export interface ClientInstanceRouterDeps {
|
|
34
34
|
now?: () => number
|
|
35
|
+
/**
|
|
36
|
+
* G2 authority Tier 1 (6.99.29): the owner-per-device map, shared so the turn provenance
|
|
37
|
+
* can ask which app copy holds the ring for a request's address. Absent: private, as before.
|
|
38
|
+
*/
|
|
39
|
+
owners?: Map<string, ClientInstanceOwner>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The ring owner's id for this device address, when it claimed within `freshMs`; else null. */
|
|
43
|
+
export function pairedInstanceFrom(owners: ReadonlyMap<string, ClientInstanceOwner>, device: string, now: number, freshMs: number): string | null {
|
|
44
|
+
const owner = owners.get(deviceKey(device))
|
|
45
|
+
return owner && now - owner.seenAt <= freshMs ? owner.id : null
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
|
|
@@ -49,7 +60,7 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
49
60
|
const opts: ClientInstanceRouterDeps = typeof deps === 'function' ? { now: deps } : deps
|
|
50
61
|
const now = opts.now ?? Date.now
|
|
51
62
|
const router = Router()
|
|
52
|
-
const owners = new Map<string, ClientInstanceOwner>()
|
|
63
|
+
const owners = opts.owners ?? new Map<string, ClientInstanceOwner>()
|
|
53
64
|
const chunks = new ClientChunkLedger()
|
|
54
65
|
// 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
|
|
55
66
|
// may take a quiet ring back. Bounded like the chunk ledger.
|
|
@@ -14,12 +14,15 @@ import express, { Router, type NextFunction, type Request, type Response } from
|
|
|
14
14
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
15
15
|
import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
|
|
16
16
|
import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
|
|
17
|
+
import { hasFooterShapedLine, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
|
|
17
18
|
|
|
18
19
|
export interface CursorStopFollowupDeps {
|
|
19
20
|
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
20
21
|
hookToken: () => string | null
|
|
21
22
|
readQueue: (provider: string, threadId: string, now: number) => QueuedThreadTurn[]
|
|
22
23
|
writeQueue: (provider: string, threadId: string, queue: QueuedThreadTurn[]) => void
|
|
24
|
+
/** G2 authority Tier 1 (6.99.29), optional: ledger the hand-off and foot a glasses or phone turn. */
|
|
25
|
+
provenance?: G2TurnProvenance
|
|
23
26
|
now: () => number
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -60,6 +63,20 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
|
|
|
60
63
|
}
|
|
61
64
|
claimed = claimNextCursorTurn(deps.readQueue('cursor', facts.conversationId, now), now)
|
|
62
65
|
if (!claimed) return res.json({})
|
|
66
|
+
// G2 authority Tier 1 (Codex re-audit of 910eb90): the queue file is mutable, and this is the
|
|
67
|
+
// last hand-off before the words reach the composer. Words carrying a footer-shaped line are
|
|
68
|
+
// refused here exactly as at every other route that hands words to a session: the row is
|
|
69
|
+
// settled refused (never handed over, never retried) and its parked origin forgotten.
|
|
70
|
+
if (deps.provenance && hasFooterShapedLine(claimed.turn.prompt)) {
|
|
71
|
+
const refusedTurn = claimed.turn
|
|
72
|
+
const settled = claimed.queue.map(t => t.clientTurnId === refusedTurn.clientTurnId
|
|
73
|
+
? { ...t, status: 'refused' as const, reason: 'footer_line_refused', settledAt: now }
|
|
74
|
+
: t)
|
|
75
|
+
deps.writeQueue('cursor', facts.conversationId, settled)
|
|
76
|
+
deps.provenance.forgetQueued('cursor', facts.conversationId, refusedTurn.clientTurnId)
|
|
77
|
+
console.warn(`[cursor-stop-followup] refused a queued turn with a footer-shaped line clientTurnId=${refusedTurn.clientTurnId}`)
|
|
78
|
+
return res.json({})
|
|
79
|
+
}
|
|
63
80
|
// Written BEFORE the text leaves: the row must never read `waiting` after the
|
|
64
81
|
// composer has been handed it, or a later Stop would hand it over again.
|
|
65
82
|
deps.writeQueue('cursor', facts.conversationId, claimed.queue)
|
|
@@ -68,7 +85,19 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
|
|
|
68
85
|
return res.json({})
|
|
69
86
|
}
|
|
70
87
|
console.log(`[cursor-stop-followup] handed a queued turn to Cursor clientTurnId=${claimed.turn.clientTurnId} chars=${claimed.turn.prompt.length} loop=${facts.loopCount ?? 'null'} cursor=${facts.cursorVersion}`)
|
|
71
|
-
|
|
88
|
+
let followup = claimed.turn.prompt
|
|
89
|
+
if (deps.provenance) {
|
|
90
|
+
// The hook's own socket is local; the origin is the one captured when the turn was
|
|
91
|
+
// parked, and only while the queued text is still the text it vouched for.
|
|
92
|
+
const turn = claimed.turn
|
|
93
|
+
const verdict = deps.provenance.queuedVerdict('cursor', facts.conversationId, turn.clientTurnId, turn.prompt) ?? deps.provenance.originOf(req)
|
|
94
|
+
followup = deps.provenance.footed(turn.prompt, turn.clientTurnId, verdict, 'cursor')
|
|
95
|
+
// The queue's own hand-off of its parked turn: it continues the record parked under this id.
|
|
96
|
+
deps.provenance.record({ turnId: turn.clientTurnId, prompt: turn.prompt, verdict, provider: 'cursor', sessionId: facts.conversationId, via: 'cursor-stop-followup', continues: true })
|
|
97
|
+
// Settled (handed over): the parked origin is forgotten (QA 6.99.29).
|
|
98
|
+
deps.provenance.forgetQueued('cursor', facts.conversationId, turn.clientTurnId)
|
|
99
|
+
}
|
|
100
|
+
return res.json({ followup_message: followup })
|
|
72
101
|
})
|
|
73
102
|
return router
|
|
74
103
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// GET /api/g2-turns/:turnId (and its 6.99.29 alias GET /api/turn-provenance/:turnId): read-only provenance for one turn this server delivered or queued
|
|
2
|
+
// into an agent session (G2 authority Tier 1, see lib/g2-turn-provenance.ts). Behind the global
|
|
3
|
+
// X-Cos-Token gate like every /api route. 400 on a malformed id, 404 `{ found: false }` when no
|
|
4
|
+
// record names it. Answers what was recorded, and vouches (`verifiedOrigin: true`) only for a
|
|
5
|
+
// glasses or phone record this process wrote and still holds, written within 24 hours. A web record
|
|
6
|
+
// is reported with `reason: web_cookie_derivable_from_token` and never vouched (6.99.29).
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
10
|
+
import { describeG2Turn, G2_TURN_ID_RE, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
|
|
11
|
+
|
|
12
|
+
export function createG2TurnsRouter(deps: {
|
|
13
|
+
provenance: G2TurnProvenance
|
|
14
|
+
now?: () => number
|
|
15
|
+
/**
|
|
16
|
+
* 6.99.29: the pairing token. With it, only a request that presents X-Cos-Token is answered: the
|
|
17
|
+
* /api gate also admits a COS Control Web browser by its cookie, and the verifier runs on this Mac
|
|
18
|
+
* with the token. Absent: whatever the gate admitted (the 6.66 tests mount the gate alone).
|
|
19
|
+
*/
|
|
20
|
+
apiToken?: () => string
|
|
21
|
+
}): Router {
|
|
22
|
+
const router = Router()
|
|
23
|
+
const now = deps.now ?? Date.now
|
|
24
|
+
// 6.99.29: one handler, two paths. `/turn-provenance` names what it is now that web turns are
|
|
25
|
+
// recorded too (reported, never vouched: lib/g2-turn-provenance.ts); `/g2-turns` stays for
|
|
26
|
+
// verify_g2_turn.py and every caller of the 6.66 design.
|
|
27
|
+
router.get(['/g2-turns/:turnId', '/turn-provenance/:turnId'], async (req, res) => {
|
|
28
|
+
res.set('Cache-Control', 'private, no-store')
|
|
29
|
+
if (deps.apiToken) {
|
|
30
|
+
let expected = ''
|
|
31
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
32
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ found: false, error: 'token_required' })
|
|
33
|
+
}
|
|
34
|
+
const turnId = String(req.params.turnId ?? '')
|
|
35
|
+
if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ found: false, error: 'invalid_turn_id' })
|
|
36
|
+
try {
|
|
37
|
+
const answer = await describeG2Turn(deps.provenance, turnId, now())
|
|
38
|
+
if (!answer) return void res.status(404).json({ found: false, turnId })
|
|
39
|
+
res.json(answer)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`[g2-turns] lookup failed: ${error instanceof Error ? error.message : error}`)
|
|
42
|
+
res.status(500).json({ found: false, error: 'lookup_failed' })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return router
|
|
46
|
+
}
|