@gotcos/glasses-server 6.61.7 → 6.62.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -121,13 +121,16 @@ import {
|
|
|
121
121
|
type BindingState,
|
|
122
122
|
type NativeBinding,
|
|
123
123
|
} from '../lib/agent-session-binding-store.js'
|
|
124
|
+
import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
|
|
124
125
|
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
125
126
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
126
127
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
|
+
import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
|
|
127
129
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
128
130
|
import {
|
|
129
131
|
CANCEL_QUEUE_HOLD_MS,
|
|
130
132
|
CLIENT_CANCEL_ID_RE,
|
|
133
|
+
DESK_RUN_EFFECT_COPY,
|
|
131
134
|
cancelRefusalCopy,
|
|
132
135
|
cancelTargetFor,
|
|
133
136
|
type CancelFacts,
|
|
@@ -257,6 +260,11 @@ export interface AttachedTurnRequest {
|
|
|
257
260
|
* simply never calls it.
|
|
258
261
|
*/
|
|
259
262
|
onSpawn: (pid: number) => boolean
|
|
263
|
+
/**
|
|
264
|
+
* 6.62.0 /qa (Q3): the client sent `X-COS-Continue-Note: 1`, i.e. it shows `continue_note`.
|
|
265
|
+
* Only then may a Cursor Continue run Run Everything; otherwise it keeps the 6.61 Ask posture.
|
|
266
|
+
*/
|
|
267
|
+
continueNoteAck?: boolean
|
|
260
268
|
/**
|
|
261
269
|
* 6.53.0: aborted by POST .../cancel. The adapter checks it before the spawn and
|
|
262
270
|
* before the prompt write, and stops the child's process group after. Optional so an
|
|
@@ -370,6 +378,29 @@ export interface AgentSessionBindingsDeps {
|
|
|
370
378
|
*/
|
|
371
379
|
deliverCodexLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; foreignHolder: boolean }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null }>
|
|
372
380
|
|
|
381
|
+
/**
|
|
382
|
+
* 6.62.0 (plan 3.6, W8): may a BUSY Codex thread the Codex app holds take a turn through
|
|
383
|
+
* that app's own queue right now? Production: `COS_CODEX_LIVE_QUEUE` is on AND the COS
|
|
384
|
+
* queue for the thread is empty (so a new turn never jumps one already waiting). Absent
|
|
385
|
+
* or anything but `true`: a busy holder refuses `native_thread_working`, as in 6.61.
|
|
386
|
+
*/
|
|
387
|
+
busyCodexHop?: (threadId: string) => boolean
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 6.62.0 (plan 3.12): what a Continue on this thread would run with, in 60 characters
|
|
391
|
+
* or less (`session-continue-facts.ts`). Added to the attachability verdict as
|
|
392
|
+
* `continue_note`. Optional; absent, null, slow (past `CONTINUE_NOTE_BUDGET_MS`) or a
|
|
393
|
+
* throw omits the field, and the verdict is unchanged.
|
|
394
|
+
*/
|
|
395
|
+
continueNote?: (provider: BindableProvider, threadId: string, options?: { noteAck: boolean }) => Promise<string | null> | string | null
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 6.62.0 /qa (W9): the note when `continueNote` misses its budget, throws, or answers
|
|
399
|
+
* nothing usable, so a Cursor Continue never runs Run Everything unannounced. Defaults to
|
|
400
|
+
* `fallbackContinueNote` with this process's environment.
|
|
401
|
+
*/
|
|
402
|
+
continueNoteFallback?: (provider: BindableProvider, options: { noteAck: boolean }) => string | null
|
|
403
|
+
|
|
373
404
|
/**
|
|
374
405
|
* The self-recursion ledger. Defaults to the real process-wide one.
|
|
375
406
|
*
|
|
@@ -406,7 +437,7 @@ export interface AgentSessionBindingsDeps {
|
|
|
406
437
|
* write a NEW session rather than the one asked for, which for a fork means the
|
|
407
438
|
* copy silently lands in the wrong project.
|
|
408
439
|
*/
|
|
409
|
-
resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null
|
|
440
|
+
resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null | Promise<string | null>
|
|
410
441
|
|
|
411
442
|
/**
|
|
412
443
|
* Where an opaque fork reference is exchanged for the thread it names.
|
|
@@ -459,7 +490,7 @@ export interface CancelDeps {
|
|
|
459
490
|
* have reached) the open session. Writes the halt marker, and re-arms it once when that
|
|
460
491
|
* turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
|
|
461
492
|
*/
|
|
462
|
-
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
|
|
493
|
+
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean; verbatim?: boolean; deferMarker?: boolean }) => boolean
|
|
463
494
|
/** Append to `data/session-cancel.jsonl`. */
|
|
464
495
|
ledger?: (row: SessionCancelLedgerRow) => void
|
|
465
496
|
}
|
|
@@ -597,6 +628,13 @@ export type WriteRefusal =
|
|
|
597
628
|
| 'fork_failed'
|
|
598
629
|
| 'fork_source_mutated'
|
|
599
630
|
| 'fork_orphan_possible'
|
|
631
|
+
// 6.62.1 (2026-10-05): the copy exists and is named, but the provider refused its
|
|
632
|
+
// first turn. Each says WHY, because the next step differs: a smaller source, a
|
|
633
|
+
// reset, or a sign-in. None is retryable: a retry makes another full copy.
|
|
634
|
+
| 'fork_turn_failed'
|
|
635
|
+
| 'fork_context_too_long'
|
|
636
|
+
| 'fork_provider_limit'
|
|
637
|
+
| 'fork_provider_auth'
|
|
600
638
|
|
|
601
639
|
/**
|
|
602
640
|
* Footer copy for the refusals that are not occupancy reasons.
|
|
@@ -680,8 +718,18 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
680
718
|
// touches it.
|
|
681
719
|
fork_source_mutated:
|
|
682
720
|
'The original thread changed while COS was copying it. Open the original on your Mac and check it before doing anything else.',
|
|
721
|
+
// 6.62.1: no "untouched" here. This outcome is reached exactly when COS could not
|
|
722
|
+
// read the copy's result, so it cannot vouch for anything after the spawn either.
|
|
683
723
|
fork_orphan_possible:
|
|
684
|
-
'COS
|
|
724
|
+
'COS did not get an answer from the copy it was making. A copy may exist on your Mac; check your sessions before forking again.',
|
|
725
|
+
fork_turn_failed:
|
|
726
|
+
'COS made the copy, but the assistant did not answer in it. Open the copy to see why before trying again.',
|
|
727
|
+
fork_context_too_long:
|
|
728
|
+
'This conversation is too long to continue in a copy: the assistant refused it as too long. Start a new session with the task instead.',
|
|
729
|
+
fork_provider_limit:
|
|
730
|
+
'The assistant hit its usage limit, so the copy got no answer. Try again after the limit resets, or start a new session with another assistant.',
|
|
731
|
+
fork_provider_auth:
|
|
732
|
+
'The assistant needs you to sign in again on this Mac before COS can copy this conversation.',
|
|
685
733
|
}
|
|
686
734
|
|
|
687
735
|
export function writeReasonCopy(reason: WriteRefusal): string {
|
|
@@ -814,7 +862,7 @@ export function projectAttachability(verdict: Occupancy): AttachabilityBody {
|
|
|
814
862
|
const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
|
|
815
863
|
// Strictly `=== true`. An idleHolder of 1, 'yes', or {} is a malformed verdict,
|
|
816
864
|
// and a malformed verdict must not buy an exemption.
|
|
817
|
-
const foreignOwnerDeclared = verdict?.idleHolder === true
|
|
865
|
+
const foreignOwnerDeclared = verdict?.idleHolder === true || verdict?.busyHolder === true
|
|
818
866
|
const sound =
|
|
819
867
|
verdict?.attachable === true &&
|
|
820
868
|
verdict.reason === null &&
|
|
@@ -1412,9 +1460,23 @@ export class ForkRefStore {
|
|
|
1412
1460
|
export type ForkOutcome =
|
|
1413
1461
|
| { kind: 'created'; newNativeThreadId: string; integrity: 'verified_unchanged' | 'unverified' }
|
|
1414
1462
|
| { kind: 'mutated' }
|
|
1415
|
-
|
|
1463
|
+
// 6.62.1: the copy is named but its first turn failed; `cause` is the provider's own
|
|
1464
|
+
// reason when its error event said one.
|
|
1465
|
+
| { kind: 'turn_failed'; newNativeThreadId: string; cause: ForkProviderFailure | null }
|
|
1466
|
+
| { kind: 'orphan_possible'; cause?: ForkProviderFailure | null }
|
|
1416
1467
|
| { kind: 'failed' }
|
|
1417
1468
|
|
|
1469
|
+
const forkProviderFailure = (detail: unknown): ForkProviderFailure | null =>
|
|
1470
|
+
typeof detail === 'string' && (FORK_PROVIDER_FAILURES as readonly string[]).includes(detail) ? detail as ForkProviderFailure : null
|
|
1471
|
+
|
|
1472
|
+
/** The refusal reason for a provider-stated cause, or the generic one. */
|
|
1473
|
+
export function forkFailureReason(cause: ForkProviderFailure | null | undefined, generic: WriteRefusal): WriteRefusal {
|
|
1474
|
+
if (cause === 'context_too_long') return 'fork_context_too_long'
|
|
1475
|
+
if (cause === 'provider_limit') return 'fork_provider_limit'
|
|
1476
|
+
if (cause === 'provider_auth') return 'fork_provider_auth'
|
|
1477
|
+
return generic
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1418
1480
|
/**
|
|
1419
1481
|
* Read a fork result without believing anything it did not say.
|
|
1420
1482
|
*
|
|
@@ -1427,7 +1489,7 @@ export type ForkOutcome =
|
|
|
1427
1489
|
*/
|
|
1428
1490
|
export function classifyFork(result: unknown, sourceNativeThreadId: string): ForkOutcome {
|
|
1429
1491
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'orphan_possible' }
|
|
1430
|
-
const { ok, newNativeThreadId, sourceIntegrity, forkState, reason } = result as Record<string, unknown>
|
|
1492
|
+
const { ok, newNativeThreadId, sourceIntegrity, forkState, reason, detail } = result as Record<string, unknown>
|
|
1431
1493
|
|
|
1432
1494
|
if (ok === true) {
|
|
1433
1495
|
// The single invariant this route re-checks itself rather than inheriting.
|
|
@@ -1449,11 +1511,19 @@ export function classifyFork(result: unknown, sourceNativeThreadId: string): For
|
|
|
1449
1511
|
if (reason === 'source_thread_mutated' || sourceIntegrity === 'mutated') return { kind: 'mutated' }
|
|
1450
1512
|
// Only an explicit "no child was created" earns the clean failure.
|
|
1451
1513
|
if (forkState === 'none') return { kind: 'failed' }
|
|
1514
|
+
// 6.62.1: a named copy is not an orphan. Same id rules as success: valid, and
|
|
1515
|
+
// never the source (that would be an append reported as a copy).
|
|
1516
|
+
if (isValidNativeThreadId(newNativeThreadId) && newNativeThreadId !== sourceNativeThreadId) {
|
|
1517
|
+
return { kind: 'turn_failed', newNativeThreadId, cause: forkProviderFailure(detail) }
|
|
1518
|
+
}
|
|
1519
|
+
return { kind: 'orphan_possible', cause: forkProviderFailure(detail) }
|
|
1452
1520
|
}
|
|
1453
1521
|
return { kind: 'orphan_possible' }
|
|
1454
1522
|
}
|
|
1455
1523
|
|
|
1456
1524
|
export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
|
|
1525
|
+
/** 6.62.1: `unverified` must never be rendered as confirmed untouched (see the success body). */
|
|
1526
|
+
export const FORKED_COPY_UNVERIFIED = 'Copied into a new thread. COS could not re-read your original afterwards to confirm it is unchanged.'
|
|
1457
1527
|
|
|
1458
1528
|
/**
|
|
1459
1529
|
* Both fingerprints present, bounded, and strings.
|
|
@@ -1500,6 +1570,12 @@ export const TURN_SENT_CODEX_QUEUE_COPY = 'Queued in the Codex app. It runs when
|
|
|
1500
1570
|
*/
|
|
1501
1571
|
export const LIVE_VERIFY_BUDGET_MS = PEER_VERIFY_TIMEOUT_MS
|
|
1502
1572
|
|
|
1573
|
+
/**
|
|
1574
|
+
* 6.62.0: how long the attachability verdict waits for its `continue_note`. The verdict is
|
|
1575
|
+
* a menu-open read; a cold Codex posture scan or a busy Cursor database must not hold it.
|
|
1576
|
+
*/
|
|
1577
|
+
export const CONTINUE_NOTE_BUDGET_MS = 1_500
|
|
1578
|
+
|
|
1503
1579
|
/**
|
|
1504
1580
|
* The 202 copy. Says QUEUED and not sent, because at this instant the provider has
|
|
1505
1581
|
* not been spawned — claiming otherwise would be the same silent-success lie the
|
|
@@ -1623,6 +1699,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1623
1699
|
}
|
|
1624
1700
|
return {
|
|
1625
1701
|
provider,
|
|
1702
|
+
threadId,
|
|
1626
1703
|
cosTurnInFlight,
|
|
1627
1704
|
runningOutsideCos,
|
|
1628
1705
|
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
@@ -1660,7 +1737,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1660
1737
|
provider: string,
|
|
1661
1738
|
threadId: string,
|
|
1662
1739
|
reached: 'none' | 'reached' | 'maybe',
|
|
1663
|
-
turn: { prompt: string; sentAt: number },
|
|
1740
|
+
turn: { prompt: string; sentAt: number; busy?: boolean },
|
|
1664
1741
|
): void => {
|
|
1665
1742
|
const latch = entry.latched
|
|
1666
1743
|
if (latch === null) return
|
|
@@ -1673,6 +1750,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1673
1750
|
if (reached !== 'none') {
|
|
1674
1751
|
target = cancelTargetFor({
|
|
1675
1752
|
provider,
|
|
1753
|
+
threadId,
|
|
1676
1754
|
cosTurnInFlight: false,
|
|
1677
1755
|
runningOutsideCos: true,
|
|
1678
1756
|
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
@@ -1685,6 +1763,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1685
1763
|
prompt: turn.prompt,
|
|
1686
1764
|
sentAt: turn.sentAt,
|
|
1687
1765
|
unverified: reached === 'maybe',
|
|
1766
|
+
// 6.62.0 (plan 3.6): a Codex turn went into the app's own queue, which hands its
|
|
1767
|
+
// text to the session's UserPromptSubmit as is: matched verbatim, no peer unwrap.
|
|
1768
|
+
...(provider === 'codex' ? { verbatim: true } : {}),
|
|
1769
|
+
// 6.62.0 /qa (W3): on a BUSY hop the session is still running Miles's OWN desk turn.
|
|
1770
|
+
// A marker written now would stop that turn at its next tool, so only the re-arm is
|
|
1771
|
+
// registered: the marker lands on the delivered turn's own UserPromptSubmit.
|
|
1772
|
+
...(turn.busy === true ? { deferMarker: true } : {}),
|
|
1688
1773
|
}) === true, false)
|
|
1689
1774
|
if (armed) {
|
|
1690
1775
|
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
@@ -1692,6 +1777,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1692
1777
|
cancelled: true,
|
|
1693
1778
|
target,
|
|
1694
1779
|
effective: 'next_tool_call',
|
|
1780
|
+
effectCopy: DESK_RUN_EFFECT_COPY,
|
|
1695
1781
|
...common,
|
|
1696
1782
|
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1697
1783
|
}
|
|
@@ -1743,8 +1829,29 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1743
1829
|
return { attachable: false, owners: [], reason: 'probe_failed' }
|
|
1744
1830
|
}
|
|
1745
1831
|
}
|
|
1832
|
+
/**
|
|
1833
|
+
* 6.62.0 (plan 3.6, W8): a WORKING Codex holder that is not ours, on a thread the hop may
|
|
1834
|
+
* take, reads attachable through a declared `busyHolder`. Shared by the probe, attach and
|
|
1835
|
+
* turns, so all three agree. Anything less than every condition keeps the verdict as is.
|
|
1836
|
+
*/
|
|
1837
|
+
const withBusyCodexHop = (provider: string, threadId: string, detected: Occupancy): Occupancy => {
|
|
1838
|
+
if (provider !== 'codex' || detected.attachable || detected.reason !== 'native_thread_working') return detected
|
|
1839
|
+
if (typeof deps.deliverCodexLiveTurn !== 'function' || typeof deps.busyCodexHop !== 'function') return detected
|
|
1840
|
+
const owners = Array.isArray(detected.owners) ? detected.owners : []
|
|
1841
|
+
if (!owners.some(owner => owner?.selfOwned !== true && owner?.source === 'codex-writer-lock')) return detected
|
|
1842
|
+
let allowed = false
|
|
1843
|
+
try {
|
|
1844
|
+
allowed = deps.busyCodexHop(threadId) === true
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
console.error(`[agent-session-bindings] busy codex hop check threw: ${error instanceof Error ? error.message : error}`)
|
|
1847
|
+
allowed = false
|
|
1848
|
+
}
|
|
1849
|
+
return allowed ? { attachable: true, owners, reason: null, busyHolder: true } : detected
|
|
1850
|
+
}
|
|
1851
|
+
const routeOccupancy = (provider: string, threadId: string): Occupancy =>
|
|
1852
|
+
withBusyCodexHop(provider, threadId, detectOccupancy(provider, threadId))
|
|
1746
1853
|
const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
|
|
1747
|
-
projectAttachability(
|
|
1854
|
+
projectAttachability(routeOccupancy(provider, threadId))
|
|
1748
1855
|
|
|
1749
1856
|
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
1750
1857
|
res.status(refusalStatus(reason)).json({
|
|
@@ -1853,7 +1960,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1853
1960
|
res.json({ released: true, target, provider: outcome.row.provider })
|
|
1854
1961
|
})
|
|
1855
1962
|
|
|
1856
|
-
|
|
1963
|
+
/**
|
|
1964
|
+
* 6.62.0 (plan 3.12): `continue_note` for the verdict, bounded in time and length. 6.62.0
|
|
1965
|
+
* /qa (W9): a read that misses the budget, throws, or answers nothing usable gets the
|
|
1966
|
+
* provider's fixed note instead, never no note (Claude has none either way).
|
|
1967
|
+
*/
|
|
1968
|
+
const continueNoteFor = async (provider: string, threadId: string, noteAck: boolean): Promise<{ continue_note?: string }> => {
|
|
1969
|
+
const read = deps.continueNote
|
|
1970
|
+
if (typeof read !== 'function' || !isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return {}
|
|
1971
|
+
const fallback = (): { continue_note?: string } => {
|
|
1972
|
+
let note: string | null = null
|
|
1973
|
+
try {
|
|
1974
|
+
note = deps.continueNoteFallback
|
|
1975
|
+
? deps.continueNoteFallback(provider, { noteAck })
|
|
1976
|
+
: fallbackContinueNote(provider, { noteAck })
|
|
1977
|
+
} catch {
|
|
1978
|
+
note = null
|
|
1979
|
+
}
|
|
1980
|
+
const bounded = boundedContinueNote(note)
|
|
1981
|
+
return bounded ? { continue_note: bounded } : {}
|
|
1982
|
+
}
|
|
1983
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
1984
|
+
let missed = false
|
|
1985
|
+
try {
|
|
1986
|
+
const note = await Promise.race([
|
|
1987
|
+
Promise.resolve(read(provider, threadId, { noteAck })),
|
|
1988
|
+
new Promise<null>(resolve => { timer = setTimeout(() => { missed = true; resolve(null) }, CONTINUE_NOTE_BUDGET_MS) }),
|
|
1989
|
+
])
|
|
1990
|
+
const bounded = boundedContinueNote(note)
|
|
1991
|
+
if (bounded) return { continue_note: bounded }
|
|
1992
|
+
if (missed) console.warn(`[agent-session-bindings] continue note missed its ${CONTINUE_NOTE_BUDGET_MS} ms budget provider=${provider}: fixed note sent`)
|
|
1993
|
+
return fallback()
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
console.error(`[agent-session-bindings] continue note failed: ${error instanceof Error ? error.message : error}`)
|
|
1996
|
+
return fallback()
|
|
1997
|
+
} finally {
|
|
1998
|
+
if (timer) clearTimeout(timer)
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
router.get('/agent-sessions/:provider/:threadId/attachability', async (req, res) => {
|
|
1857
2003
|
// An occupancy verdict is a liveness answer with a lifetime of roughly now.
|
|
1858
2004
|
// A cached `attachable: true` is indistinguishable from a stale one, which is
|
|
1859
2005
|
// the whole failure this feature exists to prevent.
|
|
@@ -1899,7 +2045,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1899
2045
|
// truncated-id hole opened. The tests pin the ordering behaviorally instead:
|
|
1900
2046
|
// probes that throw on every call still return `unsupported_provider` /
|
|
1901
2047
|
// `invalid_thread_id`, which is only possible if nothing was probed.
|
|
1902
|
-
|
|
2048
|
+
// 6.62.0 /qa (Q2): the busy Codex hop makes the verdict attachable, but a desk run IS
|
|
2049
|
+
// working on that thread, so the cancel row reads the RAW reason. Built from the verdict's
|
|
2050
|
+
// reason, the lens hid Cancel run on every busy Codex Desktop thread.
|
|
2051
|
+
const detected = routeOccupancy(provider, threadId)
|
|
2052
|
+
const verdictBody = projectAttachability(detected)
|
|
2053
|
+
const cancelReason = detected.busyHolder === true ? 'native_thread_working' : verdictBody.reason
|
|
2054
|
+
res.json({
|
|
2055
|
+
...verdictBody,
|
|
2056
|
+
cancel: cancelFieldFor(provider, threadId, cancelReason),
|
|
2057
|
+
...(await continueNoteFor(provider, threadId, continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]))),
|
|
2058
|
+
})
|
|
1903
2059
|
})
|
|
1904
2060
|
|
|
1905
2061
|
// ------------------------------------------------------------------ cancel
|
|
@@ -1989,7 +2145,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1989
2145
|
activeEntry.controller.abort()
|
|
1990
2146
|
// Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
|
|
1991
2147
|
// its app can stop. Either way only the engine's own end discounts its open turn.
|
|
1992
|
-
|
|
2148
|
+
// 6.62.0: a landed Codex or Cursor hop is a desk run COS can stop when that engine's
|
|
2149
|
+
// hooks can (cancelTargetFor's own rule); Claude keeps its 6.53.3 answer unchanged.
|
|
2150
|
+
const landedAs = provider === 'claude' ? 'desk_run'
|
|
2151
|
+
: cancelTargetFor({ ...cancelFactsFor(provider, threadId, null), cosTurnInFlight: false, runningOutsideCos: true }) === 'desk_run' ? 'desk_run' : 'unsupported'
|
|
2152
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, landedAs), undefined)
|
|
1993
2153
|
}
|
|
1994
2154
|
activeEntry.latched.replayKeys.push(replayKey)
|
|
1995
2155
|
return answer(202, 'cos_turn', 'accepted', {
|
|
@@ -2034,6 +2194,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2034
2194
|
cancelled: true,
|
|
2035
2195
|
target,
|
|
2036
2196
|
effective: 'next_tool_call',
|
|
2197
|
+
// 6.62.0 (plan 3.8): the words for what the tap did, the same for every engine.
|
|
2198
|
+
effectCopy: DESK_RUN_EFFECT_COPY,
|
|
2037
2199
|
queuedHeld: queuedHeld(),
|
|
2038
2200
|
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
2039
2201
|
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
@@ -2331,7 +2493,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2331
2493
|
if (typeof resolveWorkspace !== 'function') return refuseFork('fork_workspace_unresolvable')
|
|
2332
2494
|
let cwd: string | null = null
|
|
2333
2495
|
try {
|
|
2334
|
-
|
|
2496
|
+
// 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
|
|
2497
|
+
// store). Still before the claim below, which stays the last synchronous step.
|
|
2498
|
+
cwd = await resolveWorkspace(providerParam, threadIdParam)
|
|
2335
2499
|
} catch (error) {
|
|
2336
2500
|
console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
|
|
2337
2501
|
cwd = null
|
|
@@ -2371,14 +2535,32 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2371
2535
|
}
|
|
2372
2536
|
|
|
2373
2537
|
const outcome = classifyFork(raw, threadIdParam)
|
|
2538
|
+
{
|
|
2539
|
+
// 6.62.1: one line per attempt. On 2026-10-05 a fork's real cause existed only
|
|
2540
|
+
// in the copy's transcript; nothing reached the server log. Enums and numbers
|
|
2541
|
+
// only: no prompt, no provider text, no thread id.
|
|
2542
|
+
const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
2543
|
+
console.log(`[agent-session-bindings] fork outcome provider=${providerParam} outcome=${outcome.kind} cause=${forkProviderFailure(r.detail) ?? 'none'} exit=${typeof r.exitCode === 'number' && Number.isFinite(r.exitCode) ? r.exitCode : 'none'} ms=${typeof r.durationMs === 'number' && Number.isFinite(r.durationMs) ? r.durationMs : 'unknown'}`)
|
|
2544
|
+
}
|
|
2374
2545
|
|
|
2375
2546
|
if (outcome.kind === 'mutated') {
|
|
2376
2547
|
// The original moved. Loudest outcome in the feature, and not retryable:
|
|
2377
2548
|
// the user needs to look at their own thread before anything else touches it.
|
|
2378
2549
|
return refuseFork('fork_source_mutated', { orphanPossible: true, retryable: false })
|
|
2379
2550
|
}
|
|
2551
|
+
if (outcome.kind === 'turn_failed') {
|
|
2552
|
+
// The copy exists and Control can open it (the helper resolves the digest to the
|
|
2553
|
+
// local transcript). Not an orphan, and not retryable: another try copies again.
|
|
2554
|
+
const copyRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
|
|
2555
|
+
return refuseFork(forkFailureReason(outcome.cause, 'fork_turn_failed'), {
|
|
2556
|
+
forkRef: copyRef, turnFailed: true, failureClass: outcome.cause, orphanPossible: false, retryable: false,
|
|
2557
|
+
})
|
|
2558
|
+
}
|
|
2380
2559
|
if (outcome.kind === 'orphan_possible') {
|
|
2381
|
-
|
|
2560
|
+
const cause = outcome.cause ?? null
|
|
2561
|
+
return refuseFork(forkFailureReason(cause, 'fork_orphan_possible'), {
|
|
2562
|
+
orphanPossible: true, failureClass: cause, retryable: cause === null,
|
|
2563
|
+
})
|
|
2382
2564
|
}
|
|
2383
2565
|
if (outcome.kind === 'failed') return refuseFork('fork_failed')
|
|
2384
2566
|
|
|
@@ -2389,7 +2571,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2389
2571
|
res.status(201).json({
|
|
2390
2572
|
forked: true,
|
|
2391
2573
|
reason: null,
|
|
2392
|
-
reasonCopy: FORKED_COPY,
|
|
2574
|
+
reasonCopy: outcome.integrity === 'verified_unchanged' ? FORKED_COPY : FORKED_COPY_UNVERIFIED,
|
|
2393
2575
|
forkRef,
|
|
2394
2576
|
// Reported, never assumed. `unverified` means COS could not read the
|
|
2395
2577
|
// original at both ends — which is not the same as, and must never be
|
|
@@ -2656,14 +2838,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2656
2838
|
// session started in the gap is exactly the residual risk option B leaves
|
|
2657
2839
|
// open, and it is terminal here rather than a warning because COS has no
|
|
2658
2840
|
// cross-process lock that could fence a live desktop writer.
|
|
2659
|
-
const detected =
|
|
2841
|
+
const detected = routeOccupancy(binding.provider, binding.nativeThreadId)
|
|
2660
2842
|
const verdict = projectAttachability(detected)
|
|
2661
2843
|
stage('gate')
|
|
2662
2844
|
if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
|
|
2663
2845
|
// 6.51.0: the SAME verdict says whether a live process that is not ours holds the
|
|
2664
|
-
// thread. Only the declared idle-holder exemption
|
|
2665
|
-
// verdict (`projectAttachability` refuses anything
|
|
2666
|
-
|
|
2846
|
+
// thread. Only the declared idle-holder exemption (or, 6.62.0, the busy Codex hop)
|
|
2847
|
+
// can carry one on an attachable verdict (`projectAttachability` refuses anything
|
|
2848
|
+
// else), so this is that fact.
|
|
2849
|
+
const busyCodexHop = detected.busyHolder === true
|
|
2850
|
+
const foreignHolder = (detected.idleHolder === true || busyCodexHop)
|
|
2667
2851
|
&& Array.isArray(detected.owners)
|
|
2668
2852
|
&& detected.owners.some(owner => owner?.selfOwned !== true)
|
|
2669
2853
|
|
|
@@ -2677,7 +2861,15 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2677
2861
|
// reads as diverged, because the first turn is what moved the head.
|
|
2678
2862
|
const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
|
|
2679
2863
|
if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
|
|
2680
|
-
if (head.digest !== baseline) {
|
|
2864
|
+
if (head.digest !== baseline && busyCodexHop) {
|
|
2865
|
+
// 6.62.0 (plan 3.6): a busy thread moves while it works; that is what busy means.
|
|
2866
|
+
// The hop puts the turn into the app's own queue, which runs it after the current
|
|
2867
|
+
// turn, the same "append to whatever the thread is when it frees" the COS queue's
|
|
2868
|
+
// watermark exemption already grants a parked turn. Recorded, so the next
|
|
2869
|
+
// interactive turn compares against this head.
|
|
2870
|
+
console.log(`[agent-session-bindings] busy codex hop acknowledges a moving head turnId=${turnId} bindingId=${bindingId}`)
|
|
2871
|
+
guard.acknowledgeHead(bindingId, head.digest)
|
|
2872
|
+
} else if (head.digest !== baseline) {
|
|
2681
2873
|
// Only a changed/not-changed signal and a new opaque revision. No diff, no
|
|
2682
2874
|
// content, no path — the client is told THAT it moved, never to what.
|
|
2683
2875
|
if (acknowledged !== head.digest) {
|
|
@@ -2813,7 +3005,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2813
3005
|
if (live?.ok) {
|
|
2814
3006
|
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
2815
3007
|
const latched = latchedHere()
|
|
2816
|
-
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
3008
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2817
3009
|
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
2818
3010
|
respond(200, {
|
|
2819
3011
|
turnId,
|
|
@@ -2832,10 +3024,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2832
3024
|
// latched on the way that is the whole turn: cancelled, and no child.
|
|
2833
3025
|
const codexLatched = latchedHere()
|
|
2834
3026
|
if (codexLatched && live !== null && live.reason !== 'unverified') {
|
|
2835
|
-
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
3027
|
+
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2836
3028
|
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
2837
3029
|
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2838
3030
|
}
|
|
3031
|
+
// 6.62.0 (plan 3.6): a BUSY thread never falls through to a child (the writer lock
|
|
3032
|
+
// is held, so the spawn would fail after the prompt and fence). Whatever reached no
|
|
3033
|
+
// queue row is the 6.61 answer instead: working, and queueable, so the COS queue
|
|
3034
|
+
// takes it. A row that MAY be queued is still the ambiguous fence below.
|
|
3035
|
+
if (busyCodexHop && live !== null && live.reason !== 'unverified') {
|
|
3036
|
+
console.log(`[agent-session-bindings] busy codex hop declined provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
3037
|
+
return refuseTurn('native_thread_working')
|
|
3038
|
+
}
|
|
2839
3039
|
// The operator switched this off: nothing was queued, so the abortable
|
|
2840
3040
|
// 6.50 child path is cancellable again before it starts.
|
|
2841
3041
|
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
@@ -2860,7 +3060,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2860
3060
|
})
|
|
2861
3061
|
console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
|
|
2862
3062
|
// 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
|
|
2863
|
-
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
3063
|
+
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2864
3064
|
return reportAmbiguous()
|
|
2865
3065
|
}
|
|
2866
3066
|
// Nothing was queued, and the child cannot run against a held thread. Not
|
|
@@ -2912,6 +3112,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2912
3112
|
expectedNativeHead: head.raw,
|
|
2913
3113
|
prompt,
|
|
2914
3114
|
abortSignal: cancelController.signal,
|
|
3115
|
+
// 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
|
|
3116
|
+
continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
|
|
2915
3117
|
onSpawn: (pid: number): boolean => {
|
|
2916
3118
|
// THE SELF-RECURSION ORDER. The child registers itself against the id
|
|
2917
3119
|
// we are targeting, so unless it is in the ledger the next occupancy
|
|
@@ -79,12 +79,12 @@ import {
|
|
|
79
79
|
transcriptWatcherDegraded,
|
|
80
80
|
readTranscriptSeedLines,
|
|
81
81
|
} from '../lib/session-transcript-watcher.js'
|
|
82
|
-
import { foldSeedOutcomes, draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
|
|
82
|
+
import { cursorThoughtStatus, foldSeedOutcomes, draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
|
|
83
83
|
import type { SessionStreamState } from '../lib/session-stream-events.js'
|
|
84
84
|
import { RING_EPOCH, replaySessionStream, ringBounds } from '../lib/session-stream-bus.js'
|
|
85
85
|
import { claudeSessionsDir, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
86
86
|
import type { RegistryFacts } from '../lib/session-state-derive.js'
|
|
87
|
-
import { deriveForRow, sessionHooksEnabled, sessionSignalStore } from '../lib/session-hooks-runtime.js'
|
|
87
|
+
import { deriveForRow, sessionHooksEnabled, sessionSignalStore, signalFor } from '../lib/session-hooks-runtime.js'
|
|
88
88
|
import { derivedStatusFields } from '../lib/session-state-derive.js'
|
|
89
89
|
|
|
90
90
|
export const agentSessionStreamRouter = Router()
|
|
@@ -121,9 +121,12 @@ export const REGISTRY_REFRESH_MS = 5_000
|
|
|
121
121
|
type RegistryReader = () => Promise<RegistryFacts | undefined>
|
|
122
122
|
|
|
123
123
|
function derivedForStream(provider: AgentProvider, sessionId: string, key: string, registry: RegistryFacts | undefined): DerivedStatusFields | undefined {
|
|
124
|
-
if (
|
|
124
|
+
if (!sessionHookSseEnabled()) return undefined
|
|
125
|
+
// 6.62.0 (plan 1.3): Codex and Cursor too, once their hooks have spoken for the session. A
|
|
126
|
+
// session they have not stays exactly as before: the transcript's own status lines narrate it.
|
|
127
|
+
if (provider !== 'claude' && !signalFor(sessionId)) return undefined
|
|
125
128
|
try {
|
|
126
|
-
const derived = deriveForRow({ sessionId, registry, remember: false, attachedTurn: isAttachedTurnActive(key) })
|
|
129
|
+
const derived = deriveForRow({ sessionId, registry: provider === 'claude' ? registry : undefined, remember: false, attachedTurn: isAttachedTurnActive(key) })
|
|
127
130
|
return derived ? derivedStatusFields(derived) : undefined
|
|
128
131
|
} catch {
|
|
129
132
|
return undefined
|
|
@@ -278,7 +281,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
278
281
|
// opening state (nothing awaits once the headers are out, so a close can never slip
|
|
279
282
|
// between a write and the `close` listener), refreshed at most every REGISTRY_REFRESH_MS.
|
|
280
283
|
const readRegistry = registryReaderFor(sessionId)
|
|
281
|
-
|
|
284
|
+
// Only a Claude session has a `~/.claude/sessions` record (6.62.0: the feed now derives for all three).
|
|
285
|
+
let registry: RegistryFacts | undefined = provider === 'claude' ? await readRegistry() : undefined
|
|
282
286
|
|
|
283
287
|
res.writeHead(200, {
|
|
284
288
|
'Content-Type': 'text/event-stream',
|
|
@@ -300,6 +304,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
300
304
|
let closed = false
|
|
301
305
|
|
|
302
306
|
let releaseSignals: (() => void) | null = null
|
|
307
|
+
let releaseThoughts: (() => void) | null = null
|
|
303
308
|
|
|
304
309
|
const teardown = (): void => {
|
|
305
310
|
if (closed) return
|
|
@@ -308,6 +313,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
308
313
|
unsubscribe()
|
|
309
314
|
releaseWatcher?.()
|
|
310
315
|
releaseSignals?.()
|
|
316
|
+
releaseThoughts?.()
|
|
311
317
|
try { res.end() } catch { /* already gone */ }
|
|
312
318
|
}
|
|
313
319
|
|
|
@@ -437,7 +443,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
437
443
|
// client's state line moves on the engine's own events (prompt, permission prompt,
|
|
438
444
|
// Stop, end) rather than on the transcript clock. Filtered to this session (the
|
|
439
445
|
// client may have addressed it by the registry's 8-character form).
|
|
440
|
-
|
|
446
|
+
// 6.62.0 (plan 1.3): Codex and Cursor sessions as well, now that their hooks reach the store.
|
|
447
|
+
if (sessionHookSseEnabled() && !closed) {
|
|
441
448
|
const wanted = sessionId.toLowerCase()
|
|
442
449
|
// `lastStamp` is whatever the OPENING status actually wrote (set inside `write`), so a
|
|
443
450
|
// change during the seed read is emitted as the first live line, not lost.
|
|
@@ -446,7 +453,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
446
453
|
if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
|
|
447
454
|
// The registry may have moved with the hooks (an Esc flips it idle); refresh it
|
|
448
455
|
// off the hot path and let the next event read the new facts.
|
|
449
|
-
void readRegistry().then(facts => { registry = facts })
|
|
456
|
+
if (provider === 'claude') void readRegistry().then(facts => { registry = facts })
|
|
450
457
|
const derived = derivedForStream(provider, sessionId, key, registry)
|
|
451
458
|
if (!derived) return
|
|
452
459
|
// Only a CHANGE of state is a line; tool events inside a running turn are narrated
|
|
@@ -454,6 +461,16 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
454
461
|
if (stampOf(derived) === lastStamp) return
|
|
455
462
|
write({ kind: 'status', state: 'working', at: Date.now() })
|
|
456
463
|
})
|
|
464
|
+
// 6.62.0 (plan 1.7): a Cursor session's newest agent thought is its reasoning line, the
|
|
465
|
+
// way Codex's reasoning headline is. Memory and this feed only; never the ledger.
|
|
466
|
+
if (provider === 'cursor') {
|
|
467
|
+
releaseThoughts = sessionSignalStore.subscribeThoughts((signal, thought) => {
|
|
468
|
+
if (closed) return
|
|
469
|
+
if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
|
|
470
|
+
const draft = cursorThoughtStatus(thought.text)
|
|
471
|
+
if (draft) write({ ...draft, at: Date.now() })
|
|
472
|
+
})
|
|
473
|
+
}
|
|
457
474
|
}
|
|
458
475
|
|
|
459
476
|
releaseWatcher = path === null
|
|
@@ -466,6 +483,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
466
483
|
releaseWatcher = null
|
|
467
484
|
releaseSignals?.()
|
|
468
485
|
releaseSignals = null
|
|
486
|
+
releaseThoughts?.()
|
|
487
|
+
releaseThoughts = null
|
|
469
488
|
return
|
|
470
489
|
}
|
|
471
490
|
|