@gotcos/glasses-server 6.28.0 → 6.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/server/index.ts +45 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/occupied-threads.ts +191 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/routes/agent-session-bindings.ts +475 -1
- package/server/routes/agent-sessions.ts +62 -1
- package/server/routes/health.ts +24 -0
|
@@ -331,6 +331,39 @@ export interface AgentSessionBindingsDeps {
|
|
|
331
331
|
release: (pid: number) => boolean
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
// --------------------------------------------------------------- fork side
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* `forkThread` from `server/lib/fork-thread.ts`.
|
|
338
|
+
*
|
|
339
|
+
* Wire it as `req => forkThread({ ...req, deps: realForkDeps(watermark) })`.
|
|
340
|
+
* Unwired means the fork route refuses; it never falls back to the attached
|
|
341
|
+
* adapter, which would append to the very thread fork exists to leave alone.
|
|
342
|
+
*/
|
|
343
|
+
forkThread?: (request: ForkRouteRequest) => Promise<unknown> | unknown
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Absolute directory the forked provider run happens in, or null.
|
|
347
|
+
*
|
|
348
|
+
* SEPARATE from `resolveTarget` on purpose: that one deliberately returns only
|
|
349
|
+
* fingerprints, because plan 3.3 keeps a filesystem path off anything
|
|
350
|
+
* client-visible, and a fork needs a real path to spawn in. Wire it as
|
|
351
|
+
* `(p, id) => resolveAttachedWorkspace(p, id, deps)?.path ?? null`. Null is a
|
|
352
|
+
* refusal — `attached-workspace.ts` records that a wrong cwd makes the provider
|
|
353
|
+
* write a NEW session rather than the one asked for, which for a fork means the
|
|
354
|
+
* copy silently lands in the wrong project.
|
|
355
|
+
*/
|
|
356
|
+
resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Where an opaque fork reference is exchanged for the thread it names.
|
|
360
|
+
*
|
|
361
|
+
* Defaults to a per-router instance. Injectable so the follow-on work — teaching
|
|
362
|
+
* attach to accept a `forkRef` instead of a native id in the path — can share
|
|
363
|
+
* one store between the two routes rather than inventing a second.
|
|
364
|
+
*/
|
|
365
|
+
forkRefs?: ForkRefStore
|
|
366
|
+
|
|
334
367
|
/** Lease TTL for a new binding. */
|
|
335
368
|
attachTtlMs?: number
|
|
336
369
|
/** Upper bound on one prompt. A prompt is not a payload. */
|
|
@@ -425,6 +458,23 @@ export type WriteRefusal =
|
|
|
425
458
|
| 'provider_never_opened'
|
|
426
459
|
| 'delivery_ambiguous'
|
|
427
460
|
| 'turn_failed'
|
|
461
|
+
// ------------------------------------------------------------------- fork
|
|
462
|
+
//
|
|
463
|
+
// Fork gets its OWN members rather than reusing the ones above, and the reason
|
|
464
|
+
// is entirely in the copy. Every continuation refusal ends with the words "Fork
|
|
465
|
+
// it instead", because fork is the fallback. Reusing one of them on the fork
|
|
466
|
+
// route tells a user whose fork just failed to go and fork it — which reads as
|
|
467
|
+
// a bug, and leaves them with no next step at all. So `unsupported_provider`
|
|
468
|
+
// and `fork_unsupported_provider` are the same condition with different
|
|
469
|
+
// endings, deliberately, and neither route may borrow the other's.
|
|
470
|
+
| 'fork_unwired'
|
|
471
|
+
| 'fork_unsupported_provider'
|
|
472
|
+
| 'fork_invalid_thread_id'
|
|
473
|
+
| 'fork_workspace_unresolvable'
|
|
474
|
+
| 'fork_in_progress'
|
|
475
|
+
| 'fork_failed'
|
|
476
|
+
| 'fork_source_mutated'
|
|
477
|
+
| 'fork_orphan_possible'
|
|
428
478
|
|
|
429
479
|
/**
|
|
430
480
|
* Footer copy for the refusals that are not occupancy reasons.
|
|
@@ -482,6 +532,28 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
482
532
|
'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
|
|
483
533
|
turn_failed:
|
|
484
534
|
'COS could not run this turn. Nothing was sent. You can try again.',
|
|
535
|
+
|
|
536
|
+
// Fork copy. No sentence here may end with "Fork it instead" — this IS the fork,
|
|
537
|
+
// and pointing a failed fork back at itself is a dead end rather than an action.
|
|
538
|
+
fork_unwired:
|
|
539
|
+
'This build cannot copy a thread into a new one. Open it on your Mac instead.',
|
|
540
|
+
fork_unsupported_provider:
|
|
541
|
+
'This assistant cannot be copied into a new thread from COS yet.',
|
|
542
|
+
fork_invalid_thread_id:
|
|
543
|
+
'That thread reference is not a valid id, so there is nothing to copy.',
|
|
544
|
+
fork_workspace_unresolvable:
|
|
545
|
+
'COS could not work out where this thread lives, so it will not copy it. Open it on your Mac instead.',
|
|
546
|
+
fork_in_progress:
|
|
547
|
+
'A copy of this thread is already being made. Wait for it to finish.',
|
|
548
|
+
fork_failed:
|
|
549
|
+
'COS could not copy this thread. Your original is untouched. You can try again.',
|
|
550
|
+
// The one outcome this whole feature exists to prevent, reported plainly. No
|
|
551
|
+
// retry offered: the user needs to look at the original before anything else
|
|
552
|
+
// touches it.
|
|
553
|
+
fork_source_mutated:
|
|
554
|
+
'The original thread changed while COS was copying it. Open the original on your Mac and check it before doing anything else.',
|
|
555
|
+
fork_orphan_possible:
|
|
556
|
+
'COS lost track of the copy it was making. Your original is untouched, but a partial copy may exist on your Mac.',
|
|
485
557
|
}
|
|
486
558
|
|
|
487
559
|
export function writeReasonCopy(reason: WriteRefusal): string {
|
|
@@ -508,6 +580,7 @@ const CAPABILITY_REFUSALS: ReadonlySet<WriteRefusal> = new Set<WriteRefusal>([
|
|
|
508
580
|
'binding_registry_degraded',
|
|
509
581
|
'binding_registry_unavailable',
|
|
510
582
|
'adapter_unwired',
|
|
583
|
+
'fork_unwired',
|
|
511
584
|
])
|
|
512
585
|
|
|
513
586
|
export function refusalStatus(reason: WriteRefusal): number {
|
|
@@ -851,6 +924,118 @@ export function isOpaque(value: unknown): value is string {
|
|
|
851
924
|
return typeof value === 'string' && OPAQUE_RE.test(value)
|
|
852
925
|
}
|
|
853
926
|
|
|
927
|
+
// ------------------------------------------------------------------------ fork
|
|
928
|
+
|
|
929
|
+
/** What the route hands `forkThread`. The client supplies none of these but the prompt. */
|
|
930
|
+
export interface ForkRouteRequest {
|
|
931
|
+
provider: BindableProvider
|
|
932
|
+
nativeThreadId: string
|
|
933
|
+
prompt: string
|
|
934
|
+
/** Resolved server-side. Plan 4.2: the client never sends a path. */
|
|
935
|
+
cwd: string
|
|
936
|
+
policy: 'read_only'
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
export const FORK_REF_TTL_MS = 30 * 60_000
|
|
940
|
+
export const MAX_TRACKED_FORK_REFS = 256
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Opaque handles for freshly forked threads.
|
|
944
|
+
*
|
|
945
|
+
* The fork route must tell the client WHICH thread it created, and it may not put
|
|
946
|
+
* a native thread id on the wire — the redaction contract at the top of this file
|
|
947
|
+
* is absolute about that, and a fork id is exactly as identifying as any other.
|
|
948
|
+
* So the client gets a digest and the server keeps the mapping.
|
|
949
|
+
*
|
|
950
|
+
* NOT A CAPABILITY TOKEN, for the same reason `opaqueRevision` is not: the digest
|
|
951
|
+
* is derived from values, not from a secret, so holding one proves recognisability
|
|
952
|
+
* and nothing else. Authorization remains `requireApiToken` at the app level.
|
|
953
|
+
*
|
|
954
|
+
* Bounded and TTL'd because it is unbounded client-triggered state otherwise.
|
|
955
|
+
* Eviction is safe in the direction that matters: a lost handle means the client
|
|
956
|
+
* must find the thread on the desktop, never that something binds to the wrong one.
|
|
957
|
+
*/
|
|
958
|
+
export class ForkRefStore {
|
|
959
|
+
private readonly refs = new Map<string, { provider: BindableProvider; nativeThreadId: string; at: number }>()
|
|
960
|
+
|
|
961
|
+
remember(provider: BindableProvider, nativeThreadId: string, now: number): string {
|
|
962
|
+
const ref = opaqueRevision(targetKey(provider, nativeThreadId))
|
|
963
|
+
this.refs.delete(ref)
|
|
964
|
+
this.refs.set(ref, { provider, nativeThreadId, at: now })
|
|
965
|
+
while (this.refs.size > MAX_TRACKED_FORK_REFS) {
|
|
966
|
+
const oldest = this.refs.keys().next()
|
|
967
|
+
if (oldest.done) break
|
|
968
|
+
this.refs.delete(oldest.value)
|
|
969
|
+
}
|
|
970
|
+
return ref
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
lookup(ref: unknown, now: number): { provider: BindableProvider; nativeThreadId: string } | null {
|
|
974
|
+
if (!isOpaque(ref)) return null
|
|
975
|
+
const row = this.refs.get(ref)
|
|
976
|
+
if (!row) return null
|
|
977
|
+
if (!Number.isFinite(now) || now - row.at > FORK_REF_TTL_MS) {
|
|
978
|
+
this.refs.delete(ref)
|
|
979
|
+
return null
|
|
980
|
+
}
|
|
981
|
+
return { provider: row.provider, nativeThreadId: row.nativeThreadId }
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* What a fork attempt actually achieved.
|
|
987
|
+
*
|
|
988
|
+
* created a new thread exists and is named
|
|
989
|
+
* mutated the ORIGINAL changed — terminal, and the loudest outcome here
|
|
990
|
+
* orphan_possible something may have been created that nobody can name
|
|
991
|
+
* failed provably nothing was created
|
|
992
|
+
*/
|
|
993
|
+
export type ForkOutcome =
|
|
994
|
+
| { kind: 'created'; newNativeThreadId: string; integrity: 'verified_unchanged' | 'unverified' }
|
|
995
|
+
| { kind: 'mutated' }
|
|
996
|
+
| { kind: 'orphan_possible' }
|
|
997
|
+
| { kind: 'failed' }
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Read a fork result without believing anything it did not say.
|
|
1001
|
+
*
|
|
1002
|
+
* Recognised STRUCTURALLY rather than by importing `fork-thread.ts`, matching how
|
|
1003
|
+
* this router already reads the attached adapter: a change over there cannot break
|
|
1004
|
+
* this build, it can only stop matching — and the default for "stopped matching"
|
|
1005
|
+
* is `orphan_possible`, the cautious side. `failed` is claimed ONLY for a result
|
|
1006
|
+
* that positively says no child was ever created, because "I do not recognise this"
|
|
1007
|
+
* is not "nothing happened".
|
|
1008
|
+
*/
|
|
1009
|
+
export function classifyFork(result: unknown, sourceNativeThreadId: string): ForkOutcome {
|
|
1010
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'orphan_possible' }
|
|
1011
|
+
const { ok, newNativeThreadId, sourceIntegrity, forkState, reason } = result as Record<string, unknown>
|
|
1012
|
+
|
|
1013
|
+
if (ok === true) {
|
|
1014
|
+
// The single invariant this route re-checks itself rather than inheriting.
|
|
1015
|
+
// `fork-thread.ts` guarantees the returned id differs from the source, but
|
|
1016
|
+
// this router does not import it, so a structurally-matching object from
|
|
1017
|
+
// anywhere would otherwise be taken at its word — and the value at stake is
|
|
1018
|
+
// whether COS is about to hand the user's LIVE thread back to them labelled
|
|
1019
|
+
// as a fresh copy.
|
|
1020
|
+
if (!isValidNativeThreadId(newNativeThreadId)) return { kind: 'orphan_possible' }
|
|
1021
|
+
if (newNativeThreadId === sourceNativeThreadId) return { kind: 'mutated' }
|
|
1022
|
+
if (sourceIntegrity === 'mutated') return { kind: 'mutated' }
|
|
1023
|
+
if (sourceIntegrity !== 'verified_unchanged' && sourceIntegrity !== 'unverified') {
|
|
1024
|
+
return { kind: 'orphan_possible' }
|
|
1025
|
+
}
|
|
1026
|
+
return { kind: 'created', newNativeThreadId, integrity: sourceIntegrity }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (ok === false) {
|
|
1030
|
+
if (reason === 'source_thread_mutated' || sourceIntegrity === 'mutated') return { kind: 'mutated' }
|
|
1031
|
+
// Only an explicit "no child was created" earns the clean failure.
|
|
1032
|
+
if (forkState === 'none') return { kind: 'failed' }
|
|
1033
|
+
}
|
|
1034
|
+
return { kind: 'orphan_possible' }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
|
|
1038
|
+
|
|
854
1039
|
/**
|
|
855
1040
|
* Both fingerprints present, bounded, and strings.
|
|
856
1041
|
*
|
|
@@ -878,6 +1063,26 @@ function plainBody(req: Request): Record<string, unknown> | null {
|
|
|
878
1063
|
export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
|
|
879
1064
|
export const TURN_SENT_COPY = 'Sent to the original thread.'
|
|
880
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* The 202 copy. Says QUEUED and not sent, because at this instant the provider has
|
|
1068
|
+
* not been spawned — claiming otherwise would be the same silent-success lie the
|
|
1069
|
+
* ambiguous path exists to avoid.
|
|
1070
|
+
*/
|
|
1071
|
+
export const TURN_QUEUED_COPY = 'Queued to the original thread. It keeps running if you put your phone away.'
|
|
1072
|
+
|
|
1073
|
+
/** Status of a turn that is admitted and still running. */
|
|
1074
|
+
export const TURN_PENDING_COPY = 'Still working on your Mac.'
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Status of a turn this binding has never heard of.
|
|
1078
|
+
*
|
|
1079
|
+
* Deliberately NOT phrased as "it failed": an unknown key is far more likely to be
|
|
1080
|
+
* a client asking about a turn that never got admitted than a lost one, and
|
|
1081
|
+
* telling the user a turn failed is how a retry puts a second copy into a real
|
|
1082
|
+
* conversation.
|
|
1083
|
+
*/
|
|
1084
|
+
export const TURN_UNKNOWN_COPY = 'COS has no record of that turn. Nothing was sent.'
|
|
1085
|
+
|
|
881
1086
|
/**
|
|
882
1087
|
* Is writing into a native desktop thread turned on?
|
|
883
1088
|
*
|
|
@@ -911,6 +1116,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
911
1116
|
const detect = deps?.occupancy ?? threadOccupancy
|
|
912
1117
|
const guard = new TargetGuard()
|
|
913
1118
|
const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
|
|
1119
|
+
// One per router. Injectable so the follow-on (attach accepting a `forkRef`)
|
|
1120
|
+
// shares this instance rather than standing up a second, disconnected one.
|
|
1121
|
+
const forkRefs = deps?.forkRefs instanceof ForkRefStore ? deps.forkRefs : new ForkRefStore()
|
|
914
1122
|
const attachTtlMs =
|
|
915
1123
|
Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
|
|
916
1124
|
? (deps.attachTtlMs as number)
|
|
@@ -1219,6 +1427,174 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1219
1427
|
}
|
|
1220
1428
|
})
|
|
1221
1429
|
|
|
1430
|
+
// ------------------------------------------------------------------ fork
|
|
1431
|
+
//
|
|
1432
|
+
// The action seventeen refusal strings in this feature already recommend, and
|
|
1433
|
+
// which until now did not exist anywhere in the server or the app.
|
|
1434
|
+
//
|
|
1435
|
+
// THERE IS NO OCCUPANCY CHECK IN THIS HANDLER, AND THAT IS THE POINT. Attach and
|
|
1436
|
+
// turns both refuse when a live desktop process holds the thread, because they
|
|
1437
|
+
// are about to APPEND to it. A fork appends to nothing: it reads the source and
|
|
1438
|
+
// writes a NEW thread, verified byte-for-byte on both providers on 2026-08-16. A
|
|
1439
|
+
// live owner is therefore not a hazard here — it is the ordinary case, and the
|
|
1440
|
+
// reason the user was sent to this route in the first place. Gating fork on
|
|
1441
|
+
// occupancy would refuse precisely when it is needed and leave the user with no
|
|
1442
|
+
// path at all. Nothing in this handler may grow such a gate.
|
|
1443
|
+
//
|
|
1444
|
+
// FOR THE SAME REASON it ignores the fence, the binding registry, and
|
|
1445
|
+
// `native_target_busy`. A fenced thread is one that may hold an undelivered COS
|
|
1446
|
+
// turn, and the fence copy tells the user to go and look at it — forking it is
|
|
1447
|
+
// safe and is very often the next thing they want.
|
|
1448
|
+
//
|
|
1449
|
+
// IT IS STILL GATED ON `COS_THREAD_ATTACH_ENABLED`. Fork does not write into an
|
|
1450
|
+
// existing conversation, but it does spawn a provider CLI against the user's
|
|
1451
|
+
// workspace on their behalf, which is the same class of authority the flag
|
|
1452
|
+
// exists to hold. A disabled server holds no reachable fork code either.
|
|
1453
|
+
// UNGATED, deliberately, and this is a correction rather than an oversight.
|
|
1454
|
+
//
|
|
1455
|
+
// With fork behind the same flag, the SHIPPING DEFAULT was incoherent: seventeen
|
|
1456
|
+
// refusal strings say "Fork it instead", the lens drew an enabled Fork row, and
|
|
1457
|
+
// the tap got an Express HTML 404 with no reason and no copy. Reproduced.
|
|
1458
|
+
//
|
|
1459
|
+
// The flag exists to gate WRITING INTO AN EXISTING CONVERSATION. Fork does not do
|
|
1460
|
+
// that: it creates a NEW thread and leaves the source byte-identical, measured on
|
|
1461
|
+
// a disposable thread (original 75194 bytes before and after, a new transcript
|
|
1462
|
+
// carrying the history). So the thing the flag protects is not the thing fork
|
|
1463
|
+
// does, and gating it only removed the alternative that every refusal recommends.
|
|
1464
|
+
//
|
|
1465
|
+
// It is also what "read-only with Fork-only" means as a permanent supported
|
|
1466
|
+
// state: browse, and branch off rather than write in.
|
|
1467
|
+
router.post('/agent-sessions/:provider/:threadId/fork', async (req, res) => {
|
|
1468
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1469
|
+
|
|
1470
|
+
/** The per-source serialisation claim, released in the finally. */
|
|
1471
|
+
let claimedForkKey: string | null = null
|
|
1472
|
+
|
|
1473
|
+
const refuseFork = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
|
|
1474
|
+
if (res.headersSent) return
|
|
1475
|
+
res.status(refusalStatus(reason)).json({
|
|
1476
|
+
forked: false,
|
|
1477
|
+
forkRef: null,
|
|
1478
|
+
sourceIntegrity: null,
|
|
1479
|
+
// Default false because every refusal that reaches it directly is
|
|
1480
|
+
// pre-spawn. The paths that cannot say it override it explicitly.
|
|
1481
|
+
orphanPossible: false,
|
|
1482
|
+
retryable: true,
|
|
1483
|
+
reason,
|
|
1484
|
+
reasonCopy: writeReasonCopy(reason),
|
|
1485
|
+
...extra,
|
|
1486
|
+
})
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
try {
|
|
1490
|
+
const fork = deps.forkThread
|
|
1491
|
+
if (typeof fork !== 'function') return refuseFork('fork_unwired')
|
|
1492
|
+
|
|
1493
|
+
const body = plainBody(req)
|
|
1494
|
+
// Covers the case where no JSON parser ran at all: an unparsed body is not an
|
|
1495
|
+
// empty one.
|
|
1496
|
+
if (body === null) return refuseFork('invalid_request')
|
|
1497
|
+
|
|
1498
|
+
const cosSessionId = body.cosSessionId
|
|
1499
|
+
if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
|
|
1500
|
+
return refuseFork('invalid_request')
|
|
1501
|
+
}
|
|
1502
|
+
const prompt = body.prompt
|
|
1503
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
|
|
1504
|
+
return refuseFork('invalid_request')
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const providerParam = String(req.params.provider ?? '')
|
|
1508
|
+
const threadIdParam = String(req.params.threadId ?? '')
|
|
1509
|
+
// Validated HERE rather than inherited from an occupancy verdict, because
|
|
1510
|
+
// this route deliberately never asks for one. The id becomes a spawn
|
|
1511
|
+
// argument and a lock key, so `isValidNativeThreadId` is the whole guard.
|
|
1512
|
+
if (!isBindableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
|
|
1513
|
+
if (!isValidNativeThreadId(threadIdParam)) return refuseFork('fork_invalid_thread_id')
|
|
1514
|
+
|
|
1515
|
+
const now = readNow()
|
|
1516
|
+
if (now === null) return refuseFork('fork_failed')
|
|
1517
|
+
|
|
1518
|
+
const resolveWorkspace = deps.resolveForkWorkspace
|
|
1519
|
+
if (typeof resolveWorkspace !== 'function') return refuseFork('fork_workspace_unresolvable')
|
|
1520
|
+
let cwd: string | null = null
|
|
1521
|
+
try {
|
|
1522
|
+
cwd = resolveWorkspace(providerParam, threadIdParam)
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
|
|
1525
|
+
cwd = null
|
|
1526
|
+
}
|
|
1527
|
+
// Absolute, checked here as well as in the module. A relative path resolves
|
|
1528
|
+
// against the SERVER's cwd, so the copy would land in the wrong project while
|
|
1529
|
+
// every response looked correct.
|
|
1530
|
+
if (typeof cwd !== 'string' || cwd.length === 0 || !cwd.startsWith('/') || cwd.includes('\0')) {
|
|
1531
|
+
return refuseFork('fork_workspace_unresolvable')
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. One fork per source
|
|
1535
|
+
// thread at a time: this route spawns a provider CLI, and without a claim a
|
|
1536
|
+
// client retry loop spawns one child per request with nothing bounding it.
|
|
1537
|
+
// A DISTINCT key namespace from the turn claim, so a fork can never block a
|
|
1538
|
+
// continuation or be blocked by one — `targetKey` is length-prefixed and
|
|
1539
|
+
// therefore unambiguous, and this prefix cannot collide with one.
|
|
1540
|
+
const forkKey = `fork:${targetKey(providerParam, threadIdParam)}`
|
|
1541
|
+
if (!guard.tryClaim(forkKey, forkKey)) return refuseFork('fork_in_progress')
|
|
1542
|
+
claimedForkKey = forkKey
|
|
1543
|
+
|
|
1544
|
+
let raw: unknown
|
|
1545
|
+
try {
|
|
1546
|
+
raw = await fork({
|
|
1547
|
+
provider: providerParam,
|
|
1548
|
+
nativeThreadId: threadIdParam,
|
|
1549
|
+
prompt,
|
|
1550
|
+
cwd,
|
|
1551
|
+
// Text-only, same as the attached path. A fork runs a real model turn
|
|
1552
|
+
// against a workspace the user did not hand us explicitly.
|
|
1553
|
+
policy: 'read_only',
|
|
1554
|
+
})
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
console.error(`[agent-session-bindings] fork threw: ${error instanceof Error ? error.message : error}`)
|
|
1557
|
+
// A throw from an unknown point cannot prove no child ran.
|
|
1558
|
+
return refuseFork('fork_orphan_possible', { orphanPossible: true })
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const outcome = classifyFork(raw, threadIdParam)
|
|
1562
|
+
|
|
1563
|
+
if (outcome.kind === 'mutated') {
|
|
1564
|
+
// The original moved. Loudest outcome in the feature, and not retryable:
|
|
1565
|
+
// the user needs to look at their own thread before anything else touches it.
|
|
1566
|
+
return refuseFork('fork_source_mutated', { orphanPossible: true, retryable: false })
|
|
1567
|
+
}
|
|
1568
|
+
if (outcome.kind === 'orphan_possible') {
|
|
1569
|
+
return refuseFork('fork_orphan_possible', { orphanPossible: true })
|
|
1570
|
+
}
|
|
1571
|
+
if (outcome.kind === 'failed') return refuseFork('fork_failed')
|
|
1572
|
+
|
|
1573
|
+
// Digest, not the id. The native thread id never crosses this boundary, for
|
|
1574
|
+
// a fresh fork exactly as for an existing thread.
|
|
1575
|
+
const forkRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
|
|
1576
|
+
|
|
1577
|
+
res.status(201).json({
|
|
1578
|
+
forked: true,
|
|
1579
|
+
reason: null,
|
|
1580
|
+
reasonCopy: FORKED_COPY,
|
|
1581
|
+
forkRef,
|
|
1582
|
+
// Reported, never assumed. `unverified` means COS could not read the
|
|
1583
|
+
// original at both ends — which is not the same as, and must never be
|
|
1584
|
+
// rendered as, "confirmed untouched".
|
|
1585
|
+
sourceIntegrity: outcome.integrity,
|
|
1586
|
+
orphanPossible: false,
|
|
1587
|
+
})
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
console.error(`[agent-session-bindings] fork route failed: ${error instanceof Error ? error.message : error}`)
|
|
1590
|
+
// A bug in this handler cannot prove whether a child ran, so it reports the
|
|
1591
|
+
// cautious outcome rather than a clean failure.
|
|
1592
|
+
refuseFork('fork_orphan_possible', { orphanPossible: true })
|
|
1593
|
+
} finally {
|
|
1594
|
+
if (claimedForkKey !== null) guard.release(claimedForkKey, claimedForkKey)
|
|
1595
|
+
}
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1222
1598
|
// ----------------------------------------------------------------- turns
|
|
1223
1599
|
if (attachEnabled) router.post('/agent-sessions/bindings/:bindingId/turns', async (req, res) => {
|
|
1224
1600
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -1238,6 +1614,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1238
1614
|
/** Client idempotency key and its binding. Null until the body is validated. */
|
|
1239
1615
|
let clientTurnId: string | null = null
|
|
1240
1616
|
let ledgerBindingId: string | null = null
|
|
1617
|
+
/**
|
|
1618
|
+
* Has the 202 already gone out, leaving the ledger as the ONLY way to report
|
|
1619
|
+
* what happened?
|
|
1620
|
+
*
|
|
1621
|
+
* A provider turn runs for minutes — up to a 21 minute default — and the phone
|
|
1622
|
+
* cannot hold a request open across that: iOS suspends the WebView the moment
|
|
1623
|
+
* it is backgrounded. So every gate below runs synchronously, and delivery
|
|
1624
|
+
* alone is backgrounded once the last gate passes.
|
|
1625
|
+
*/
|
|
1626
|
+
let queued = false
|
|
1241
1627
|
|
|
1242
1628
|
const respond = (status: number, payload: Record<string, unknown>): void => {
|
|
1243
1629
|
if (!res.headersSent) res.status(status).json(payload)
|
|
@@ -1249,8 +1635,15 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1249
1635
|
// `completed` and `ambiguous` are the two that must never run twice. Measured
|
|
1250
1636
|
// 2026-08-16: two byte-identical POSTs both returned completed and the user's
|
|
1251
1637
|
// real transcript ended up with two copies of the turn.
|
|
1638
|
+
// Once queued, the ledger is the ONLY reporting channel — the 202 is long
|
|
1639
|
+
// gone. A post-202 refusal that recorded nothing would leave the status route
|
|
1640
|
+
// answering `pending` forever, which reads to the user as a turn still
|
|
1641
|
+
// running when it was actually refused minutes ago. Pre-202 refusals keep the
|
|
1642
|
+
// old semantics deliberately: they stay re-evaluatable, because the binding
|
|
1643
|
+
// may well be fine by the time the client retries.
|
|
1252
1644
|
const outcome = payload.outcome
|
|
1253
|
-
|
|
1645
|
+
const terminal = outcome === 'completed' || outcome === 'ambiguous' || (queued && outcome === 'refused')
|
|
1646
|
+
if (clientTurnId !== null && ledgerBindingId !== null && terminal) {
|
|
1254
1647
|
try {
|
|
1255
1648
|
deps.bindings.recordTurn?.(ledgerBindingId, clientTurnId, { ...payload, status }, readNow() ?? requestNow)
|
|
1256
1649
|
} catch (error) {
|
|
@@ -1433,6 +1826,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1433
1826
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
1434
1827
|
pinnedBindingId = bindingId
|
|
1435
1828
|
|
|
1829
|
+
// THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
|
|
1830
|
+
// lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
|
|
1831
|
+
// refusal still reaches the user immediately and precisely. Only the spawn is
|
|
1832
|
+
// backgrounded, because only the spawn takes minutes.
|
|
1833
|
+
//
|
|
1834
|
+
// Nothing below changes. `respond` already writes to the response only when
|
|
1835
|
+
// headers have not been sent, and to the ledger regardless, so each terminal
|
|
1836
|
+
// outcome now lands in the ledger and the status route serves it.
|
|
1837
|
+
queued = true
|
|
1838
|
+
res.status(202).json({
|
|
1839
|
+
turnId,
|
|
1840
|
+
outcome: 'queued',
|
|
1841
|
+
clientTurnId,
|
|
1842
|
+
bindingId,
|
|
1843
|
+
deliveryState: 'pending',
|
|
1844
|
+
retryable: false,
|
|
1845
|
+
changed: false,
|
|
1846
|
+
revision: null,
|
|
1847
|
+
reason: null,
|
|
1848
|
+
reasonCopy: TURN_QUEUED_COPY,
|
|
1849
|
+
})
|
|
1850
|
+
|
|
1436
1851
|
let delivery: Delivery
|
|
1437
1852
|
deliveryAttempted = true
|
|
1438
1853
|
try {
|
|
@@ -1546,5 +1961,64 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1546
1961
|
}
|
|
1547
1962
|
})
|
|
1548
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* What happened to a queued turn.
|
|
1966
|
+
*
|
|
1967
|
+
* Reads the durable turn ledger, which is the same record the replay path serves,
|
|
1968
|
+
* so a poll and a retry can never disagree about what a turn did.
|
|
1969
|
+
*
|
|
1970
|
+
* Gated with the write routes: this reports on attached turns, and an install
|
|
1971
|
+
* that cannot make them has nothing to report on.
|
|
1972
|
+
*/
|
|
1973
|
+
if (attachEnabled) router.get('/agent-sessions/bindings/:bindingId/turns/:clientTurnId', (req, res) => {
|
|
1974
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1975
|
+
|
|
1976
|
+
const bindingId = String(req.params.bindingId ?? '')
|
|
1977
|
+
const clientTurnId = String(req.params.clientTurnId ?? '')
|
|
1978
|
+
if (!BINDING_ID_RE.test(bindingId) || !CLIENT_TURN_ID_RE.test(clientTurnId)) {
|
|
1979
|
+
res.status(400).json({ outcome: 'invalid_request', reasonCopy: TURN_UNKNOWN_COPY })
|
|
1980
|
+
return
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
let entry: { result?: unknown } | null = null
|
|
1984
|
+
try {
|
|
1985
|
+
entry = deps.bindings.findTurn?.(bindingId, clientTurnId) ?? null
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
console.error(`[agent-session-bindings] turn status read failed: ${error instanceof Error ? error.message : error}`)
|
|
1988
|
+
// A ledger read that THREW is not a turn that did not happen. Reporting
|
|
1989
|
+
// `unknown` here would invite the retry that double-posts.
|
|
1990
|
+
res.status(503).json({ outcome: 'unavailable', reasonCopy: TURN_PENDING_COPY })
|
|
1991
|
+
return
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (entry === null) {
|
|
1995
|
+
// Genuinely absent. Admitted-and-running is indistinguishable from
|
|
1996
|
+
// never-admitted in the ledger alone, so this stays 404 and the copy avoids
|
|
1997
|
+
// asserting either.
|
|
1998
|
+
res.status(404).json({ outcome: 'unknown', reasonCopy: TURN_UNKNOWN_COPY })
|
|
1999
|
+
return
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const stored = entry.result && typeof entry.result === 'object'
|
|
2003
|
+
? entry.result as Record<string, unknown>
|
|
2004
|
+
: null
|
|
2005
|
+
if (stored === null) {
|
|
2006
|
+
res.status(200).json({ outcome: 'pending', reasonCopy: TURN_PENDING_COPY })
|
|
2007
|
+
return
|
|
2008
|
+
}
|
|
2009
|
+
// `status` is the ledger's record of the ORIGINAL response code and must not
|
|
2010
|
+
// become this poll's status — a refused turn reported correctly is a successful
|
|
2011
|
+
// read.
|
|
2012
|
+
// Surfaced under its own name rather than dropped: the code the turn actually
|
|
2013
|
+
// produced is exactly what a caller that missed the 202's eventual outcome
|
|
2014
|
+
// needs in order to react the way it would have to the original response.
|
|
2015
|
+
const { status: recorded, ...rest } = stored
|
|
2016
|
+
res.status(200).json({
|
|
2017
|
+
...rest,
|
|
2018
|
+
recordedStatus: typeof recorded === 'number' ? recorded : null,
|
|
2019
|
+
polled: true,
|
|
2020
|
+
})
|
|
2021
|
+
})
|
|
2022
|
+
|
|
1549
2023
|
return router
|
|
1550
2024
|
}
|
|
@@ -32,6 +32,9 @@ import {
|
|
|
32
32
|
import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
|
|
33
33
|
import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
|
|
34
34
|
import { workspaceFromCwd } from '../lib/claude-session-registry.js'
|
|
35
|
+
import { occupiedThreads, noOccupancyKnown, type OccupiedScan, type OccupiedThread } from '../lib/occupied-threads.js'
|
|
36
|
+
import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
|
|
37
|
+
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
35
38
|
|
|
36
39
|
export const agentSessionsRouter = Router()
|
|
37
40
|
|
|
@@ -61,6 +64,60 @@ function toSearchHit(row: AgentSessionSearchHit) {
|
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Which of these sessions a desktop process is holding right now.
|
|
69
|
+
*
|
|
70
|
+
* ONE scan for the whole page. Calling `threadOccupancy` per row would re-read the
|
|
71
|
+
* registry and shell out to `ps` per entry, which at 53 sessions is hundreds of
|
|
72
|
+
* process spawns on a single list request.
|
|
73
|
+
*
|
|
74
|
+
* THIS IS A DISPLAY HINT AND MUST NEVER GATE A WRITE. The attach and turn routes
|
|
75
|
+
* keep probing at the moment of the write, unchanged, because a list is rendered
|
|
76
|
+
* seconds or minutes before the user acts and a desktop session opened in that gap
|
|
77
|
+
* is exactly the race the per-write probe exists to catch.
|
|
78
|
+
*/
|
|
79
|
+
function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
|
|
80
|
+
try {
|
|
81
|
+
const dirs = realOccupancyDirs()
|
|
82
|
+
// The spawn ledger is what lets a turn COS itself queued read as ours rather
|
|
83
|
+
// than as a foreign desktop window holding the thread.
|
|
84
|
+
const probes = realOccupancyProbes(cosSpawnedPids)
|
|
85
|
+
const byProvider = new Map<string, string[]>()
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
if (row.provider !== 'claude' && row.provider !== 'codex') continue
|
|
88
|
+
const list = byProvider.get(row.provider) ?? []
|
|
89
|
+
list.push(row.session_id)
|
|
90
|
+
byProvider.set(row.provider, list)
|
|
91
|
+
}
|
|
92
|
+
const merged = new Map<string, OccupiedThread>()
|
|
93
|
+
let degraded = false
|
|
94
|
+
for (const [provider, ids] of byProvider) {
|
|
95
|
+
const scan = occupiedThreads(provider, ids, probes, dirs)
|
|
96
|
+
for (const [id, occ] of scan.occupied) merged.set(id, occ)
|
|
97
|
+
if (scan.degraded) degraded = true
|
|
98
|
+
}
|
|
99
|
+
return { occupied: merged, degraded }
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// The list is the point; occupancy is decoration. A probe failure must never
|
|
102
|
+
// cost the user their sessions.
|
|
103
|
+
console.error(`[agent-sessions] occupancy scan failed: ${error instanceof Error ? error.message : error}`)
|
|
104
|
+
return noOccupancyKnown()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Stamp the running hint onto a projected row. */
|
|
109
|
+
function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
|
|
110
|
+
const occ = scan.occupied.get(entry.session_id)
|
|
111
|
+
return {
|
|
112
|
+
...entry,
|
|
113
|
+
// An agent is working in this thread right now, whoever started it.
|
|
114
|
+
running: occ !== undefined,
|
|
115
|
+
// Held by something that is not COS, so a Continue would be refused. The
|
|
116
|
+
// badge reads `running`; the Continue affordance reads this.
|
|
117
|
+
running_foreign: (occ?.foreignOwners ?? 0) > 0,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
64
121
|
function toEntry(row: AgentSessionRow) {
|
|
65
122
|
return {
|
|
66
123
|
session_id: row.session_id,
|
|
@@ -107,12 +164,16 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
107
164
|
const sort = asSort(req.query.sort)
|
|
108
165
|
const live = await liveClaudeRows()
|
|
109
166
|
const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
|
|
167
|
+
const running = runningThreads(sessions)
|
|
110
168
|
res.json({
|
|
111
|
-
sessions: sessions.map(toEntry),
|
|
169
|
+
sessions: sessions.map(row => withRunning(toEntry(row), running)),
|
|
112
170
|
total: sessions.length,
|
|
113
171
|
windowHours: AGENT_SESSION_WINDOW_HOURS,
|
|
114
172
|
sort,
|
|
115
173
|
enabled: true,
|
|
174
|
+
// True when a probe could not see clearly. The client must render "unknown"
|
|
175
|
+
// rather than treating a quiet scan as "nothing is running".
|
|
176
|
+
runningDegraded: running.degraded,
|
|
116
177
|
})
|
|
117
178
|
} catch (error) {
|
|
118
179
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|