@gotcos/glasses-server 6.99.33 → 7.0.18
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 +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -46,6 +46,12 @@ export interface WorkActivity {
|
|
|
46
46
|
* `unknown` (older clients); with this flag it is "Sending", not an unresolved delivery. Absent otherwise.
|
|
47
47
|
*/
|
|
48
48
|
sending?: true
|
|
49
|
+
/**
|
|
50
|
+
* 7.0.1: when the current needs-input or blocked report was made: the newest progress event of that kind (Control
|
|
51
|
+
* records each report as an event at the reply's own time). Absent for any other report, or when no event says.
|
|
52
|
+
* Today dates a waiting row by this, not by the card's newest event.
|
|
53
|
+
*/
|
|
54
|
+
waitingSince?: string
|
|
49
55
|
}
|
|
50
56
|
/** 6.59.0 (S4): where Control's saved draft for the task's current revision sends. Never the draft's prompt. */
|
|
51
57
|
export interface SavedDestination { mode: WorkHandoffMode; provider: string; model?: string; sessionId?: string }
|
|
@@ -69,7 +75,9 @@ type JournalRecord = Record<string, unknown>
|
|
|
69
75
|
export interface WorkJournal { available: true; receipts: JournalRecord[]; drafts: JournalRecord[] }
|
|
70
76
|
export type WorkJournalRead = WorkJournal | { available: false; reason: string }
|
|
71
77
|
|
|
72
|
-
export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600
|
|
78
|
+
export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600,
|
|
79
|
+
/** 7.0.1: session activity this long after a needs-input or blocked report supersedes it (the reply that carried it is not). */
|
|
80
|
+
supersedeSeconds: 60 } as const
|
|
73
81
|
/** Control's `TaskRow.workStages` (and task-store WORK_STAGES). A move naming anything else is not projected. */
|
|
74
82
|
export const WORK_ACTIVITY_STAGES: readonly string[] = ['mentioned', 'planned', 'draft', 'built', 'qa', 'complete']
|
|
75
83
|
const unavailableJournal = (reason: string): WorkJournalRead => ({ available: false, reason })
|
|
@@ -147,7 +155,7 @@ export function cleanEvidence(value: unknown): string | undefined {
|
|
|
147
155
|
* The receipt's optional `progress` block, parsed on its own. Absent is undefined; a malformed block reads
|
|
148
156
|
* `{ unreadable: true }` and never makes the journal unavailable: Control writes it, and a newer Control may write more.
|
|
149
157
|
*/
|
|
150
|
-
function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number } | undefined {
|
|
158
|
+
function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number; reportAt?: number } | undefined {
|
|
151
159
|
if (raw === undefined || raw === null) return undefined
|
|
152
160
|
const unreadable = { projection: { unreadable: true as const } }
|
|
153
161
|
if (!object(raw)) return unreadable
|
|
@@ -173,13 +181,20 @@ function parseProgress(raw: unknown): { projection: WorkActivityProgress | Unrea
|
|
|
173
181
|
}
|
|
174
182
|
const received = isoFromSeconds(receivedAt)
|
|
175
183
|
if (received) projection.receivedAt = received
|
|
184
|
+
// 7.0.1: when it started waiting. Control records a session's report as an event of the report's own kind, and a Jev
|
|
185
|
+
// reading as a 'jev' event; the newest such event is the report standing now.
|
|
186
|
+
let reportAt: number | undefined
|
|
187
|
+
if (state === 'needsInput' || state === 'blocked') {
|
|
188
|
+
const kinds = projection.reportedBy === 'jev' ? [state, 'jev'] : [state]
|
|
189
|
+
for (const event of list as JournalRecord[]) if (kinds.includes(event.kind as string)) reportAt = reportAt === undefined ? event.at as number : Math.max(reportAt, event.at as number)
|
|
190
|
+
}
|
|
176
191
|
// The newest move COS made, undone or not (Control's timeline order: events are appended).
|
|
177
192
|
const move = [...list].reverse().find(event => (event as JournalRecord).kind === 'moved') as JournalRecord | undefined
|
|
178
193
|
const moveAt = move && isoFromSeconds(move.at)
|
|
179
194
|
if (move && moveAt && WORK_ACTIVITY_STAGES.includes(move.fromStage as string) && WORK_ACTIVITY_STAGES.includes(move.toStage as string)) {
|
|
180
195
|
projection.lastMove = { from: move.fromStage as string, to: move.toStage as string, at: moveAt, undone: finite(move.undoneAt) }
|
|
181
196
|
}
|
|
182
|
-
return { projection, newestAt }
|
|
197
|
+
return { projection, newestAt, reportAt }
|
|
183
198
|
}
|
|
184
199
|
/** The report a progress projection carries, or null (none, or unreadable). */
|
|
185
200
|
export function reportOf(progress: WorkActivity['progress']): WorkActivityProgress['reported'] {
|
|
@@ -229,6 +244,8 @@ function projectReceipt(receipt: JournalRecord, domain: string, all: readonly Jo
|
|
|
229
244
|
? isoFromSeconds(Math.max(receipt.createdAt as number, progress.newestAt)) ?? createdAt : createdAt
|
|
230
245
|
}
|
|
231
246
|
if (progress) activity.progress = progress.projection
|
|
247
|
+
const waitingSince = isoFromSeconds(progress?.reportAt)
|
|
248
|
+
if (waitingSince) activity.waitingSince = waitingSince
|
|
232
249
|
// Control's handledByYou: acknowledged, or an unknown or delivered handoff you marked reviewed.
|
|
233
250
|
activity.acknowledged = finite(receipt.acknowledgedAt) || raw === 'reviewed'
|
|
234
251
|
// Ownership is the SESSION's, across every receipt that names it (Control's appOwner and serverHold look at all of them).
|
|
@@ -384,9 +401,33 @@ export function savedDestinationFor(journal: WorkJournal, row: WorkBoardRowLite)
|
|
|
384
401
|
return draft ? draftDestination(draft) : undefined
|
|
385
402
|
}
|
|
386
403
|
|
|
404
|
+
/**
|
|
405
|
+
* 7.0.1: the time a report was superseded, or nothing. The report's time is when it started waiting (`waitingSince`),
|
|
406
|
+
* else the card's newest event. `sessionActivityMs` is the newest turn Miles started in the session
|
|
407
|
+
* (work-session-activity.ts: a real prompt, never the session's own work, a background agent's notification or a tool
|
|
408
|
+
* result; review W3). It has to come more than `supersedeSeconds` after the report, a grace for the tracker's clock.
|
|
409
|
+
*/
|
|
410
|
+
function supersededBy(activity: WorkActivity, sessionActivityMs: ReadonlyMap<string, number> | undefined): string | undefined {
|
|
411
|
+
if (!activity.sessionId || !sessionActivityMs) return undefined
|
|
412
|
+
const last = sessionActivityMs.get(activity.sessionId)
|
|
413
|
+
const since = Date.parse(activity.waitingSince ?? activity.updatedAt ?? '')
|
|
414
|
+
if (last === undefined || !Number.isFinite(last) || !Number.isFinite(since)) return undefined
|
|
415
|
+
return last - since > WORK_ACTIVITY_LIMITS.supersedeSeconds * 1000 ? new Date(last).toISOString() : undefined
|
|
416
|
+
}
|
|
417
|
+
|
|
387
418
|
/** S2 groups, in order. */
|
|
388
419
|
export type OpenWorkGroup = 'needsInput' | 'attention' | 'running' | 'done'
|
|
389
|
-
export interface OpenWorkItem extends WorkActivity {
|
|
420
|
+
export interface OpenWorkItem extends WorkActivity {
|
|
421
|
+
workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination
|
|
422
|
+
/**
|
|
423
|
+
* 7.0.1: the needs-input or blocked report was superseded: you started a turn in its session after the report, at
|
|
424
|
+
* this time. The item is grouped as if no report stood. The report itself stays in
|
|
425
|
+
* `progress`, as Control recorded it.
|
|
426
|
+
*/
|
|
427
|
+
supersededAt?: string
|
|
428
|
+
}
|
|
429
|
+
/** 7.0.1: what the open list knows about each linked session: the newest turn you started in it, ms, by `provider:native`. */
|
|
430
|
+
export interface OpenWorkOptions { sessionActivityMs?: ReadonlyMap<string, number> }
|
|
390
431
|
export interface OpenWorkList { items: OpenWorkItem[]; total: number; truncated: boolean }
|
|
391
432
|
const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1, running: 2, done: 3 }
|
|
392
433
|
|
|
@@ -400,8 +441,14 @@ const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1,
|
|
|
400
441
|
* A report counts only on a receipt that did not fail, was not refused and not canceled (Control's WorkTracking.latest).
|
|
401
442
|
* A checked task shows only while its handoff is queued or running (Control's board). Groups in that order, the most
|
|
402
443
|
* recently updated first within each, cut to 50 after sorting; `total` counts them all. Read-only.
|
|
444
|
+
*
|
|
445
|
+
* 7.0.1 (Miles 11/07, a row "Waiting for your reply" since Oct 31 on a session he talks to all day): a needs-input or
|
|
446
|
+
* blocked report stands only until its session moves on. Control's tracker takes a report from the session's status
|
|
447
|
+
* line for this task, so a later turn that never mentions the task again leaves the old report in place for the whole
|
|
448
|
+
* 14 days. When `options.sessionActivityMs` shows a turn you started in the session more than a minute after the report,
|
|
449
|
+
* the report no longer groups the item. The session's own work never does (review W3).
|
|
403
450
|
*/
|
|
404
|
-
export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number): OpenWorkList {
|
|
451
|
+
export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number, options: OpenWorkOptions = {}): OpenWorkList {
|
|
405
452
|
const newest = new Map<string, JournalRecord>()
|
|
406
453
|
for (const receipt of journal.receipts) {
|
|
407
454
|
const prior = newest.get(receipt.workID as string)
|
|
@@ -419,7 +466,9 @@ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRo
|
|
|
419
466
|
if (!receipt || nowSeconds - (receipt.createdAt as number) >= WORK_ACTIVITY_LIMITS.trackedDays * 86_400) continue
|
|
420
467
|
const activity = projectReceipt(receipt, row.domain, journal.receipts, nowMs)
|
|
421
468
|
const status = activity.status, handled = activity.acknowledged === true
|
|
422
|
-
|
|
469
|
+
let reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
|
|
470
|
+
const supersededAt = reported === 'needsInput' || reported === 'blocked' ? supersededBy(activity, options.sessionActivityMs) : undefined
|
|
471
|
+
if (supersededAt) reported = null
|
|
423
472
|
// A send in flight is running, not unresolved.
|
|
424
473
|
const sending = activity.sending === true
|
|
425
474
|
const group: OpenWorkGroup | null = (reported === 'needsInput' || reported === 'blocked') && !handled ? 'needsInput'
|
|
@@ -431,6 +480,7 @@ export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRo
|
|
|
431
480
|
// Control drops a completed task's card unless its handoff is still in progress.
|
|
432
481
|
if (row.checked === true && !sending && !['queued', 'running'].includes(status)) continue
|
|
433
482
|
const item: OpenWorkItem = { ...activity, workIdentity: identity, title: typeof row.title === 'string' ? row.title : '', taskRevision: controlSnapshotRevision(row), group }
|
|
483
|
+
if (supersededAt) item.supersededAt = supersededAt
|
|
434
484
|
const destination = savedDestinationFor(journal, row)
|
|
435
485
|
if (destination) item.savedDestination = destination
|
|
436
486
|
const updatedAt = activity.updatedAt ? Date.parse(activity.updatedAt) : 0
|
|
@@ -34,6 +34,18 @@ export const epochSeconds = (v: unknown): number | null => typeof v === 'number'
|
|
|
34
34
|
const sidOrNull = (v: unknown): string | null => typeof v === 'string' && SID.test(v.toLowerCase()) ? v.toLowerCase() : null
|
|
35
35
|
|
|
36
36
|
export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
|
|
37
|
+
return parseSnapshotReport(v, now).snapshot
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 7.0.13: the snapshot plus how many live rows were left out. Live rows are evidence only (what the session list
|
|
42
|
+
* showed), so a row COS cannot name by a full session id, or whose text breaks a bound, is skipped and counted instead
|
|
43
|
+
* of refusing the whole snapshot. Control 0.5.384 sent a running Claude CLI peer as `claude:<8 hex>` (the prefix id
|
|
44
|
+
* /api/claude-sessions serves) and every Work chat plan on every card failed with 400 invalid_snapshot. A row that is
|
|
45
|
+
* not an object, and every link and receipt, stay strict: links and receipts decide which sessions belong to a card.
|
|
46
|
+
*/
|
|
47
|
+
export function parseSnapshotReport(v: unknown, now = Date.now()): { snapshot: ControlSnapshot; droppedLiveRows: number } {
|
|
48
|
+
let droppedLiveRows = 0
|
|
37
49
|
if (!obj(v) || v.version !== SNAPSHOT_VERSION || !str(v.controlVersion, 40) || !Array.isArray(v.links) || v.links.length > SNAPSHOT_MAX_LINKS
|
|
38
50
|
|| !Array.isArray(v.receipts) || v.receipts.length > SNAPSHOT_MAX_RECEIPTS) throw new Error('invalid_snapshot')
|
|
39
51
|
const links: SnapshotLink[] = []
|
|
@@ -56,15 +68,17 @@ export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
|
|
|
56
68
|
if (!obj(v.live) || checkedAt === null || !Array.isArray(v.live.rows) || v.live.rows.length > SNAPSHOT_MAX_LIVE) throw new Error('invalid_live')
|
|
57
69
|
const rows: SnapshotLiveRow[] = []
|
|
58
70
|
for (const r of v.live.rows) {
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
if (!obj(r)) throw new Error('invalid_live_row')
|
|
72
|
+
const id = sidOrNull(r.id)
|
|
73
|
+
if (!id) { droppedLiveRows++; continue }
|
|
74
|
+
if (!str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) { droppedLiveRows++; continue }
|
|
61
75
|
if (isGuardianRow(id, r.title ?? '', r.summary ?? '')) continue
|
|
62
76
|
rows.push({ id, title: r.title ?? '', status: r.status ?? '', updatedAt: str(r.updatedAt, 40) ? r.updatedAt : null,
|
|
63
77
|
busy: r.busy === true, waitingOnUser: r.waitingOnUser === true, appOwned: r.appOwned === true, summary: r.summary ?? '' })
|
|
64
78
|
}
|
|
65
79
|
live = { checkedAt, rows }
|
|
66
80
|
}
|
|
67
|
-
return { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }
|
|
81
|
+
return { snapshot: { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }, droppedLiveRows }
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
/** Codex guardian review threads (canary C3: 17 in the live list) are never offered as work sessions. */
|
|
@@ -112,6 +112,19 @@ export class WorkReviewRuntime {
|
|
|
112
112
|
return { review: publicReview(await this.reconcileRow(row, true)), replayed: false }
|
|
113
113
|
})
|
|
114
114
|
}
|
|
115
|
+
/** S1 (2026-11-09): write a reconciled row only when something other than updatedAt changed.
|
|
116
|
+
* The 5 s timer reconciles every row; a row whose meeting stays unavailable came back identical
|
|
117
|
+
* except for a fresh updatedAt, and each put is an fsync'd rewrite of the whole journal. On
|
|
118
|
+
* Miles's Mac three such rows rewrote the 114 KB journal three times every 5 s (about 68 KB/s,
|
|
119
|
+
* some 60% of the server's idle disk writes). An unchanged row keeps its original updatedAt. */
|
|
120
|
+
private saveIfChanged(before: StoredWorkReview, after: StoredWorkReview): StoredWorkReview {
|
|
121
|
+
const { updatedAt: _a, ...left } = before
|
|
122
|
+
const { updatedAt: _b, ...right } = after
|
|
123
|
+
if (JSON.stringify(left) === JSON.stringify(right)) return before
|
|
124
|
+
after.updatedAt = this.now()
|
|
125
|
+
this.deps.store.put(after)
|
|
126
|
+
return after
|
|
127
|
+
}
|
|
115
128
|
private async reconcileRow(row: StoredWorkReview, allowAdmission: boolean): Promise<StoredWorkReview> {
|
|
116
129
|
if (row.status === 'superseded') return row
|
|
117
130
|
const updated = structuredClone(row)
|
|
@@ -119,12 +132,12 @@ export class WorkReviewRuntime {
|
|
|
119
132
|
const current = await this.deps.resolveMeeting(row.source.descriptor); assertRecord(current, row.source.descriptor)
|
|
120
133
|
if (sourceRevision(current) !== row.source.revision) {
|
|
121
134
|
updated.status = 'superseded'; updated.error = { code: 'meeting_revision_changed', message: 'The saved meeting changed. Request a new review for its current revision.' }
|
|
122
|
-
|
|
135
|
+
return this.saveIfChanged(row, updated)
|
|
123
136
|
}
|
|
124
137
|
} catch {
|
|
125
138
|
if (row.status === 'ready' || row.status === 'failed') updated.suspendedTerminal = { status: row.status, ...(row.error ? { error: row.error } : {}) }
|
|
126
139
|
updated.status = 'source_unavailable'; updated.error = { code: 'meeting_source_unavailable', message: 'The original saved source cannot be verified. No new work was started.' }
|
|
127
|
-
|
|
140
|
+
return this.saveIfChanged(row, updated)
|
|
128
141
|
}
|
|
129
142
|
if (row.status === 'source_unavailable' && row.suspendedTerminal) {
|
|
130
143
|
// Terminal receipts outlive query-job retention. Restore only after the
|
|
@@ -132,7 +145,7 @@ export class WorkReviewRuntime {
|
|
|
132
145
|
updated.status = row.suspendedTerminal.status
|
|
133
146
|
if (row.suspendedTerminal.error) updated.error = row.suspendedTerminal.error; else delete updated.error
|
|
134
147
|
delete updated.suspendedTerminal
|
|
135
|
-
|
|
148
|
+
return this.saveIfChanged(row, updated)
|
|
136
149
|
}
|
|
137
150
|
if (row.status === 'ready' || row.status === 'failed') return row
|
|
138
151
|
try {
|
|
@@ -170,7 +183,7 @@ export class WorkReviewRuntime {
|
|
|
170
183
|
if (updated.status === 'ready') { updated.status = 'source_unavailable'; delete updated.markdown }
|
|
171
184
|
updated.error = { code: e instanceof WorkReviewError ? e.code : 'review_reconciliation_unavailable', message: 'Review reconciliation is unavailable. No automatic duplicate is submitted.' }
|
|
172
185
|
}
|
|
173
|
-
|
|
186
|
+
return this.saveIfChanged(row, updated)
|
|
174
187
|
}
|
|
175
188
|
async reconcile(allowAdmission = false): Promise<void> {
|
|
176
189
|
await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.0.1 (Needs you stale): when Miles last started a turn in each Work session, for the open list's supersede rule
|
|
3
|
+
* (work-activity.ts projectOpenWork, `sessionActivityMs`).
|
|
4
|
+
*
|
|
5
|
+
* Miles, 11/07: Today showed "Waiting for your reply" on a Work card from Oct 31 while he talked to its session all
|
|
6
|
+
* day. Control's tracker had taken a needs-input report from the session's status line for that task, and no later
|
|
7
|
+
* turn mentioned the task again, so the report stood. The session's own transcript says he moved on.
|
|
8
|
+
*
|
|
9
|
+
* ONLY A TURN HE STARTED COUNTS (review W3). The session's own work is not an answer: a background agent finishing,
|
|
10
|
+
* a tool result, a Monitor or /loop wakeup, a message from another session, a system reminder or a compaction summary
|
|
11
|
+
* all write records into the transcript with nobody typing. Counting those cleared a report Miles still owed.
|
|
12
|
+
* - Claude: a `user` record that is a real prompt: not `isMeta`, not a compaction summary, not a tool result, not a
|
|
13
|
+
* task notification, and from a human origin when the record says where it came from (`origin.kind` and
|
|
14
|
+
* `turnOrigin` are `human` on a typed prompt; `peer`, `task-notification`, `system` and `sdk` are not). Its text,
|
|
15
|
+
* with system reminders taken out, has to say something. Checked against the record shapes of a real transcript.
|
|
16
|
+
* - Codex: a `response_item` message with role `user` whose text is not an injected block (`<environment_context>`,
|
|
17
|
+
* `# AGENTS.md instructions`, the files-mentioned list and the like).
|
|
18
|
+
* - Cursor: transcripts carry no per-record clock or origin, so a Cursor session answers its file's write time. Any
|
|
19
|
+
* write moves it, so a Cursor report can clear on the agent's own work. A listed limit.
|
|
20
|
+
*
|
|
21
|
+
* BOUNDED (review W4): at most `sessions` sessions per call, the stalest waiting reports first (waitingSessionIds);
|
|
22
|
+
* the Claude transcript index is built once per call; each session's answer is cached for `cacheMs`; concurrent
|
|
23
|
+
* callers share one read per session; and `timeoutMs` is a deadline the walk itself checks between folders, sessions
|
|
24
|
+
* and read windows, so a slow disk stops the work, not only the wait. A session it cannot find or read in time is left
|
|
25
|
+
* out, which keeps its report standing: the old behaviour, never a guessed clear.
|
|
26
|
+
*/
|
|
27
|
+
import { open, stat } from 'node:fs/promises'
|
|
28
|
+
import {
|
|
29
|
+
agentSessionRoots, codexSessionRootsOf, fileStat, findAgentSessionFile, listCodexJsonlFiles, loadClaudeDesktopAliases, loadClaudeTranscriptIndex,
|
|
30
|
+
type AgentSessionRoots,
|
|
31
|
+
} from './agent-session-store.js'
|
|
32
|
+
import { parseWorkSessionId } from './work-activity.js'
|
|
33
|
+
|
|
34
|
+
export const WORK_SESSION_ACTIVITY_LIMITS = {
|
|
35
|
+
sessions: 12,
|
|
36
|
+
timeoutMs: 2_000,
|
|
37
|
+
/** One session's answer is reused this long: a list poll and the badge recount never walk the disk again inside it. */
|
|
38
|
+
cacheMs: 30_000,
|
|
39
|
+
/** First window read from the end of a transcript, widened x4 while no human turn is found. */
|
|
40
|
+
tailBytes: 256 * 1024,
|
|
41
|
+
/** Largest window: a session working for hours since his last prompt writes a lot of tool output. */
|
|
42
|
+
tailMaxBytes: 64 * 1024 * 1024,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
export interface WorkSessionActivityOptions {
|
|
46
|
+
/** Fixture roots. Without them, an isolated runtime (tests) reads nothing rather than this Mac's transcripts. */
|
|
47
|
+
roots?: AgentSessionRoots
|
|
48
|
+
env?: NodeJS.ProcessEnv
|
|
49
|
+
timeoutMs?: number
|
|
50
|
+
/** Test seam for the cache and deadline clock. */
|
|
51
|
+
now?: () => number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Rec = Record<string, unknown>
|
|
55
|
+
const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
|
|
56
|
+
/** Text a Claude user record carries that nobody typed. */
|
|
57
|
+
const CLAUDE_INJECTED = /^(<task-notification>|<local-command-stdout>|<local-command-stderr>|<local-command-caveat>|Caveat: The messages below were generated)/
|
|
58
|
+
/** Codex user-role blocks the app injects around a prompt. */
|
|
59
|
+
const CODEX_INJECTED = /^(<environment_context>|<user_instructions>|<external_[a-z_]+>|# AGENTS\.md instructions|# Files mentioned by the user)/
|
|
60
|
+
|
|
61
|
+
function originKind(obj: Rec): string | null {
|
|
62
|
+
const origin = obj.origin
|
|
63
|
+
if (origin && typeof origin === 'object' && typeof (origin as Rec).kind === 'string') return (origin as Rec).kind as string
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A Claude transcript record that is a turn Miles started. Exported for tests. */
|
|
68
|
+
export function claudeHumanTurn(obj: Rec): boolean {
|
|
69
|
+
if (obj.type !== 'user' || obj.isMeta === true || obj.isCompactSummary === true || obj.isVisibleInTranscriptOnly === true || obj.toolUseResult) return false
|
|
70
|
+
const kind = originKind(obj)
|
|
71
|
+
if (kind !== null && kind !== 'human') return false
|
|
72
|
+
if (kind === null && typeof obj.turnOrigin === 'string' && obj.turnOrigin !== 'human') return false
|
|
73
|
+
const content = (obj.message as Rec | undefined)?.content
|
|
74
|
+
const blocks: Rec[] = typeof content === 'string' ? [{ type: 'text', text: content }]
|
|
75
|
+
: Array.isArray(content) ? content.filter((b): b is Rec => !!b && typeof b === 'object') : []
|
|
76
|
+
if (blocks.some(b => b.type === 'tool_result')) return false
|
|
77
|
+
let said = false
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
if (block.type === 'image') { said = true; continue }
|
|
80
|
+
if (block.type !== 'text' || typeof block.text !== 'string') continue
|
|
81
|
+
const text = block.text.replace(SYSTEM_REMINDER, '').trim()
|
|
82
|
+
if (!text) continue
|
|
83
|
+
if (CLAUDE_INJECTED.test(text)) return false
|
|
84
|
+
said = true
|
|
85
|
+
}
|
|
86
|
+
return said
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A Codex rollout record that is a message Miles sent. Exported for tests. */
|
|
90
|
+
export function codexHumanTurn(obj: Rec): boolean {
|
|
91
|
+
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return false
|
|
92
|
+
const payload = obj.payload as Rec
|
|
93
|
+
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) return false
|
|
94
|
+
return payload.content.some(b => {
|
|
95
|
+
if (!b || typeof b !== 'object') return false
|
|
96
|
+
const block = b as Rec
|
|
97
|
+
if (block.type === 'input_image') return true
|
|
98
|
+
if (typeof block.text !== 'string') return false
|
|
99
|
+
const text = block.text.trim()
|
|
100
|
+
return !!text && !CODEX_INJECTED.test(text)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The newest human turn's time in a transcript tail, ms, or null. Exported for tests. */
|
|
105
|
+
export function lastHumanTurnFromTail(provider: 'claude' | 'codex', text: string, startsMidFile: boolean): number | null {
|
|
106
|
+
const lines = text.split('\n')
|
|
107
|
+
if (startsMidFile) lines.shift()
|
|
108
|
+
const isHuman = provider === 'claude' ? claudeHumanTurn : codexHumanTurn
|
|
109
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
110
|
+
const line = lines[i]!
|
|
111
|
+
// Cheap prefilter before a parse: every candidate names a user role.
|
|
112
|
+
if (!line.includes('"user"')) continue
|
|
113
|
+
let obj: unknown
|
|
114
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
115
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj) || !isHuman(obj as Rec)) continue
|
|
116
|
+
const at = Date.parse(String((obj as Rec).timestamp ?? ''))
|
|
117
|
+
if (Number.isFinite(at)) return at
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const turnMemo = new Map<string, { mtimeMs: number; size: number; at: number | null }>()
|
|
123
|
+
const TURN_MEMO_MAX = 200
|
|
124
|
+
|
|
125
|
+
/** One transcript's newest human turn, memoized on mtime and size. undefined: the deadline came first. */
|
|
126
|
+
async function readLastHumanTurn(provider: 'claude' | 'codex', file: string, pastDeadline: () => boolean): Promise<number | null | undefined> {
|
|
127
|
+
const st = await stat(file)
|
|
128
|
+
const hit = turnMemo.get(file)
|
|
129
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.at
|
|
130
|
+
let at: number | null = null
|
|
131
|
+
const handle = await open(file, 'r')
|
|
132
|
+
try {
|
|
133
|
+
for (let window: number = WORK_SESSION_ACTIVITY_LIMITS.tailBytes; ; window *= 4) {
|
|
134
|
+
if (pastDeadline()) return undefined
|
|
135
|
+
const length = Math.min(st.size, window), start = st.size - length
|
|
136
|
+
const buffer = Buffer.alloc(length)
|
|
137
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start)
|
|
138
|
+
at = lastHumanTurnFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0)
|
|
139
|
+
if (at !== null || start === 0 || window >= WORK_SESSION_ACTIVITY_LIMITS.tailMaxBytes) break
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
await handle.close()
|
|
143
|
+
}
|
|
144
|
+
turnMemo.delete(file)
|
|
145
|
+
while (turnMemo.size >= TURN_MEMO_MAX) { const oldest = turnMemo.keys().next().value; if (oldest === undefined) break; turnMemo.delete(oldest) }
|
|
146
|
+
turnMemo.set(file, { mtimeMs: st.mtimeMs, size: st.size, at })
|
|
147
|
+
return at
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Per session: the answer and when it was read. `at` null: read, and no human turn (or no transcript, or unreadable). */
|
|
151
|
+
const cache = new Map<string, { at: number | null; readAt: number }>()
|
|
152
|
+
const inflight = new Map<string, Promise<number | null | undefined>>()
|
|
153
|
+
const CACHE_MAX = 500
|
|
154
|
+
let walks = 0
|
|
155
|
+
|
|
156
|
+
function cacheKey(roots: AgentSessionRoots, id: string): string { return `${roots.claudeProjects}\u0000${roots.cursorProjects}\u0000${id}` }
|
|
157
|
+
|
|
158
|
+
/** Newest human turn, ms since 1970, of each session it could read, keyed by the id exactly as given. */
|
|
159
|
+
export async function workSessionActivity(sessionIds: readonly string[], options: WorkSessionActivityOptions = {}): Promise<Map<string, number>> {
|
|
160
|
+
const found = new Map<string, number>()
|
|
161
|
+
const env = options.env ?? process.env
|
|
162
|
+
if (!options.roots && (env.NODE_ENV === 'test' || env.VITEST)) return found
|
|
163
|
+
const roots = options.roots ?? agentSessionRoots()
|
|
164
|
+
const now = options.now ?? (() => Date.now())
|
|
165
|
+
const ids = [...new Set(sessionIds)].filter(id => parseWorkSessionId(id) !== null).slice(0, WORK_SESSION_ACTIVITY_LIMITS.sessions)
|
|
166
|
+
if (!ids.length) return found
|
|
167
|
+
const started = now(), deadline = started + (options.timeoutMs ?? WORK_SESSION_ACTIVITY_LIMITS.timeoutMs)
|
|
168
|
+
const pastDeadline = () => now() >= deadline
|
|
169
|
+
const keep = (id: string, at: number | null | undefined) => { if (typeof at === 'number' && Number.isFinite(at)) found.set(id, at) }
|
|
170
|
+
|
|
171
|
+
const missing: string[] = []
|
|
172
|
+
const waits: Array<Promise<void>> = []
|
|
173
|
+
for (const id of ids) {
|
|
174
|
+
const key = cacheKey(roots, id), hit = cache.get(key)
|
|
175
|
+
if (hit && started - hit.readAt < WORK_SESSION_ACTIVITY_LIMITS.cacheMs) { keep(id, hit.at); continue }
|
|
176
|
+
const running = inflight.get(key)
|
|
177
|
+
if (running) waits.push(running.then(at => keep(id, at)))
|
|
178
|
+
else missing.push(id)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (missing.length) {
|
|
182
|
+
// One walk for every missing session. Each session's promise is registered first, so a concurrent caller waits on
|
|
183
|
+
// this read instead of starting its own.
|
|
184
|
+
const settle = new Map<string, (at: number | null | undefined) => void>()
|
|
185
|
+
for (const id of missing) {
|
|
186
|
+
const key = cacheKey(roots, id)
|
|
187
|
+
const one = new Promise<number | null | undefined>(resolve => settle.set(id, resolve))
|
|
188
|
+
inflight.set(key, one)
|
|
189
|
+
waits.push(one.then(at => keep(id, at)))
|
|
190
|
+
}
|
|
191
|
+
void (async () => {
|
|
192
|
+
let indexed = false
|
|
193
|
+
let claudeFiles: Map<string, string[]> | undefined
|
|
194
|
+
let codexFiles: string[] | undefined
|
|
195
|
+
let aliases: Awaited<ReturnType<typeof loadClaudeDesktopAliases>> | undefined
|
|
196
|
+
for (const id of missing) {
|
|
197
|
+
const key = cacheKey(roots, id)
|
|
198
|
+
let at: number | null | undefined
|
|
199
|
+
try {
|
|
200
|
+
const { provider, native } = parseWorkSessionId(id)!
|
|
201
|
+
if (!pastDeadline() && !indexed) {
|
|
202
|
+
indexed = true
|
|
203
|
+
walks += 1
|
|
204
|
+
if (missing.some(m => parseWorkSessionId(m)!.provider === 'claude')) {
|
|
205
|
+
aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
206
|
+
claudeFiles = await loadClaudeTranscriptIndex(roots, pastDeadline)
|
|
207
|
+
}
|
|
208
|
+
if (!pastDeadline() && missing.some(m => parseWorkSessionId(m)!.provider === 'codex')) {
|
|
209
|
+
codexFiles = (await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))).flat()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Past the deadline (a walk it cut short is not a complete index, and resolving against it could pick the
|
|
213
|
+
// wrong file): left out this time and read again on the next call.
|
|
214
|
+
if (pastDeadline()) at = undefined
|
|
215
|
+
else {
|
|
216
|
+
const file = await findAgentSessionFile(provider as 'claude' | 'codex' | 'cursor', native, roots, new Date(), aliases, { claudeFiles, codexFiles })
|
|
217
|
+
if (!file) at = null
|
|
218
|
+
else if (provider === 'cursor') at = (await fileStat(file))?.mtimeMs ?? null
|
|
219
|
+
else at = await readLastHumanTurn(provider as 'claude' | 'codex', file, pastDeadline)
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
at = null // unreadable: its report stands, and the answer is cached like any other
|
|
223
|
+
}
|
|
224
|
+
if (at !== undefined) {
|
|
225
|
+
cache.delete(key)
|
|
226
|
+
while (cache.size >= CACHE_MAX) { const oldest = cache.keys().next().value; if (oldest === undefined) break; cache.delete(oldest) }
|
|
227
|
+
cache.set(key, { at, readAt: now() })
|
|
228
|
+
}
|
|
229
|
+
inflight.delete(key)
|
|
230
|
+
settle.get(id)!(at)
|
|
231
|
+
}
|
|
232
|
+
})()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let timer: NodeJS.Timeout | undefined
|
|
236
|
+
await Promise.race([Promise.all(waits), new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - now())) })])
|
|
237
|
+
clearTimeout(timer)
|
|
238
|
+
// A copy: a shared read may still land in `found` after the deadline.
|
|
239
|
+
return new Map(found)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The sessions whose report the open list would show as waiting: the ones worth reading. The stalest report first
|
|
244
|
+
* (review N7): under the 12-session cap the oldest waits are the likeliest to be stale, like the Oct 31 row.
|
|
245
|
+
*/
|
|
246
|
+
export function waitingSessionIds(items: ReadonlyArray<{ group: string; sessionId?: string; waitingSince?: string; updatedAt?: string }>): string[] {
|
|
247
|
+
const when = (item: { waitingSince?: string; updatedAt?: string }) => {
|
|
248
|
+
const t = Date.parse(item.waitingSince ?? item.updatedAt ?? '')
|
|
249
|
+
return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY
|
|
250
|
+
}
|
|
251
|
+
const out: string[] = []
|
|
252
|
+
for (const item of items.filter(i => i.group === 'needsInput' && i.sessionId).sort((a, b) => when(a) - when(b))) {
|
|
253
|
+
if (!out.includes(item.sessionId!)) out.push(item.sessionId!)
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Test seams. */
|
|
259
|
+
export function clearWorkSessionActivityCache(): void { cache.clear(); inflight.clear(); turnMemo.clear(); walks = 0 }
|
|
260
|
+
export function workSessionActivityWalks(): number { return walks }
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* next poll tries again.
|
|
10
10
|
*/
|
|
11
11
|
import { projectOpenWork, readWorkJournal, type OpenWorkGroup } from './work-activity.js'
|
|
12
|
+
import { waitingSessionIds } from './work-session-activity.js'
|
|
12
13
|
|
|
13
14
|
type Journal = ReturnType<typeof readWorkJournal>
|
|
14
15
|
type Rows = Parameters<typeof projectOpenWork>[1]
|
|
@@ -17,6 +18,8 @@ export interface YourMoveCounterDeps {
|
|
|
17
18
|
journal: () => Journal
|
|
18
19
|
/** The board rows, or null when the board cannot be read in time. */
|
|
19
20
|
rows: () => Promise<Rows | null>
|
|
21
|
+
/** 7.0.1: each linked session's newest activity, so a superseded report is not counted (the open list's own rule). */
|
|
22
|
+
sessionActivity?: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
|
|
20
23
|
now?: () => number
|
|
21
24
|
ttlMs?: number
|
|
22
25
|
}
|
|
@@ -25,9 +28,9 @@ export const YOUR_MOVE_TTL_MS = 30_000
|
|
|
25
28
|
const WAITING_GROUPS: readonly OpenWorkGroup[] = ['needsInput', 'attention', 'done']
|
|
26
29
|
|
|
27
30
|
/** The pure count: open-work items whose group waits on you. */
|
|
28
|
-
export function countYourMove(journal: Journal, rows: Rows, nowMs: number): number {
|
|
31
|
+
export function countYourMove(journal: Journal, rows: Rows, nowMs: number, sessionActivityMs?: ReadonlyMap<string, number>): number {
|
|
29
32
|
if (!journal.available) return 0
|
|
30
|
-
return projectOpenWork(journal, rows, nowMs).items.filter(item => WAITING_GROUPS.includes(item.group)).length
|
|
33
|
+
return projectOpenWork(journal, rows, nowMs, { sessionActivityMs }).items.filter(item => WAITING_GROUPS.includes(item.group)).length
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<number | null> {
|
|
@@ -44,7 +47,10 @@ export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<
|
|
|
44
47
|
if (!journal.available) return null
|
|
45
48
|
const rows = await deps.rows()
|
|
46
49
|
if (!rows) return null
|
|
47
|
-
const
|
|
50
|
+
const at = now()
|
|
51
|
+
const waiting = deps.sessionActivity && journal.available ? waitingSessionIds(projectOpenWork(journal, rows, at).items) : []
|
|
52
|
+
const activity = waiting.length ? await deps.sessionActivity!(waiting).catch(() => undefined) : undefined
|
|
53
|
+
const value = countYourMove(journal, rows, at, activity)
|
|
48
54
|
cached = { at: now(), value }
|
|
49
55
|
return value
|
|
50
56
|
} catch {
|
|
@@ -459,6 +459,13 @@ export interface AgentSessionBindingsDeps {
|
|
|
459
459
|
*/
|
|
460
460
|
sessionSteer?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
|
|
461
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Steer mid-turn (COS_STEER_MIDTURN): would a message parked at this busy thread go into its
|
|
464
|
+
* running turn at the next tool step? Added to a busy attachability answer as `midTurn`, so the
|
|
465
|
+
* composer says which line is true. Null (field absent) with the switch off.
|
|
466
|
+
*/
|
|
467
|
+
sessionMidTurn?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
|
|
468
|
+
|
|
462
469
|
ownership?: {
|
|
463
470
|
record: (pid: number, startMs: number) => string
|
|
464
471
|
release: (pid: number) => boolean
|
|
@@ -2164,8 +2171,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2164
2171
|
const detected = routeOccupancy(provider, threadId)
|
|
2165
2172
|
const verdictBody = projectAttachability(detected)
|
|
2166
2173
|
const cancelReason = detected.busyHolder === true ? 'native_thread_working' : verdictBody.reason
|
|
2174
|
+
// Steer mid-turn: only on a busy answer a turn may queue behind, and only with the switch on.
|
|
2175
|
+
let midTurn: SessionSteerAvailability | null = null
|
|
2176
|
+
if (verdictBody.attachable === false && typeof verdictBody.reason === 'string' && queueableRefusal(verdictBody.reason) && typeof deps.sessionMidTurn === 'function') {
|
|
2177
|
+
try { midTurn = deps.sessionMidTurn(provider, threadId, verdictBody.reason) } catch { midTurn = null }
|
|
2178
|
+
}
|
|
2167
2179
|
res.json({
|
|
2168
2180
|
...verdictBody,
|
|
2181
|
+
...(midTurn ? { midTurn } : {}),
|
|
2169
2182
|
cancel: cancelFieldFor(provider, threadId, cancelReason),
|
|
2170
2183
|
...(await continueNoteFor(provider, threadId, continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]))),
|
|
2171
2184
|
})
|
|
@@ -2741,8 +2754,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2741
2754
|
// 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
|
|
2742
2755
|
// that actually receives the words, and the child has no binding yet. Until a destination
|
|
2743
2756
|
// binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
|
|
2744
|
-
|
|
2757
|
+
// 6.99.31: a signed web fork is judged too, so its record says why (always fork_destination_unbound).
|
|
2745
2758
|
const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
|
|
2759
|
+
const forkVerdict = deps.provenance.acceptWebKey(unvouched(deps.provenance.originOf(req), 'fork_destination_unbound'), { provider: providerParam, sessionId: null, turnId: forkTurnId, words: prompt, attachments: [], fork: true })
|
|
2746
2760
|
forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
|
|
2747
2761
|
deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
|
|
2748
2762
|
}
|
|
@@ -3126,6 +3140,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3126
3140
|
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3127
3141
|
}
|
|
3128
3142
|
}
|
|
3143
|
+
// 6.99.31: a web turn carrying an enrolled phone key's envelope is judged once the binding names
|
|
3144
|
+
// the real target (below). Its files are read here (the only await): the envelope's ordered
|
|
3145
|
+
// manifest must equal them. Null: unreadable, so the turn is not vouched.
|
|
3146
|
+
let webKeyFiles: ReadonlyArray<{ id: string; sha256: string }> | null = []
|
|
3147
|
+
if (turnVerdict?.webKeyCandidate && attachIds.ids.length > 0) {
|
|
3148
|
+
const m = await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
|
|
3149
|
+
webKeyFiles = m.ok ? m.manifest : null
|
|
3150
|
+
}
|
|
3129
3151
|
/** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
|
|
3130
3152
|
const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
|
|
3131
3153
|
if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
|
|
@@ -3201,6 +3223,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3201
3223
|
console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
|
|
3202
3224
|
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3203
3225
|
}
|
|
3226
|
+
// 6.99.31: the envelope against the binding's provider and native thread (the actual receiving
|
|
3227
|
+
// session), the exact words (before the files block) and the files. Synchronous: the nonce is
|
|
3228
|
+
// spent here, in the same tick as the reuse check below. No envelope: the verdict is unchanged.
|
|
3229
|
+
if (provenance && turnVerdict && turnVerdict.webKeyCandidate) {
|
|
3230
|
+
turnVerdict = provenance.acceptWebKey(turnVerdict, { provider: binding.provider, sessionId: binding.nativeThreadId, bindingId, turnId: clientTurnId, words, attachments: webKeyFiles })
|
|
3231
|
+
}
|
|
3204
3232
|
if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
|
|
3205
3233
|
return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
3206
3234
|
}
|
|
@@ -59,6 +59,8 @@
|
|
|
59
59
|
// never be mistaken for a user's explicit opt-out. Off answers 503 and the client
|
|
60
60
|
// polls, which is the same code path as a server too old to have this route at all.
|
|
61
61
|
|
|
62
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
63
|
+
import { redactLocalPaths } from '../lib/local-path-redact.js'
|
|
62
64
|
import { Router, type Response } from 'express'
|
|
63
65
|
import { stat } from 'node:fs/promises'
|
|
64
66
|
import {
|
|
@@ -213,6 +215,16 @@ export async function openingState(
|
|
|
213
215
|
*/
|
|
214
216
|
export const SEED_EVENTS = 7
|
|
215
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A stream event as it leaves for a client. 7.0.4 final QA: a `prompt` is redacted like the user turns in `?turns=`
|
|
220
|
+
* history (redactLocalPathsDeep, not a command field), so the lens's prompt match (findTurnStart) still finds its turn.
|
|
221
|
+
* Every other kind is unchanged.
|
|
222
|
+
*/
|
|
223
|
+
export function clientStreamEvent<T extends { kind: string }>(event: T): T {
|
|
224
|
+
const text = (event as { text?: unknown }).text
|
|
225
|
+
return event.kind === 'prompt' && typeof text === 'string' ? { ...event, text: redactLocalPaths(text) } : event
|
|
226
|
+
}
|
|
227
|
+
|
|
216
228
|
agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', async (req, res) => {
|
|
217
229
|
res.set('Cache-Control', 'private, no-store')
|
|
218
230
|
|
|
@@ -331,6 +343,11 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
331
343
|
lastStamp = stampOf(derived)
|
|
332
344
|
out = { ...event, ...statusDraftWithDerived(event, derived) }
|
|
333
345
|
}
|
|
346
|
+
// 7.0.4 final QA: the turn history (`?turns=`) redacts local paths in user turns, and the lens finds the turn on
|
|
347
|
+
// screen by matching this prompt's opening words against them (cos-glasses-app session-conversation.ts
|
|
348
|
+
// findTurnStart). An unredacted prompt with a Mac path never matched, so the lens refetched and showed the turn
|
|
349
|
+
// twice. The prompt leaves redacted the same way; server-side consumers keep the raw event.
|
|
350
|
+
out = clientStreamEvent(out)
|
|
334
351
|
const idLine = typeof out.cursor === 'number' ? `id: ${out.epoch ?? RING_EPOCH}.${out.cursor}\n` : ''
|
|
335
352
|
res.write(`${idLine}data: ${JSON.stringify({ seq: ++seq, ...out })}\n\n`)
|
|
336
353
|
} catch {
|
|
@@ -350,6 +367,12 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
350
367
|
teardown()
|
|
351
368
|
return
|
|
352
369
|
}
|
|
370
|
+
// Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies (signed out
|
|
371
|
+
// everywhere, expired, past the 30-day cap). A token stream never re-checks.
|
|
372
|
+
if (!streamSignInHolds(res)) {
|
|
373
|
+
teardown()
|
|
374
|
+
return
|
|
375
|
+
}
|
|
353
376
|
write({ kind: 'heartbeat', at: Date.now() })
|
|
354
377
|
}, HEARTBEAT_INTERVAL_MS)
|
|
355
378
|
if (typeof (heartbeat as any).unref === 'function') (heartbeat as any).unref()
|