@gotcos/glasses-server 6.99.29 → 7.0.7

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.
Files changed (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -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 } as const
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 { workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination }
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
- const reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
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
@@ -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 value = countYourMove(journal, rows, now())
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
  })
@@ -59,6 +59,7 @@
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 { redactLocalPaths } from '../lib/local-path-redact.js'
62
63
  import { Router, type Response } from 'express'
63
64
  import { stat } from 'node:fs/promises'
64
65
  import {
@@ -213,6 +214,16 @@ export async function openingState(
213
214
  */
214
215
  export const SEED_EVENTS = 7
215
216
 
217
+ /**
218
+ * A stream event as it leaves for a client. 7.0.4 final QA: a `prompt` is redacted like the user turns in `?turns=`
219
+ * history (redactLocalPathsDeep, not a command field), so the lens's prompt match (findTurnStart) still finds its turn.
220
+ * Every other kind is unchanged.
221
+ */
222
+ export function clientStreamEvent<T extends { kind: string }>(event: T): T {
223
+ const text = (event as { text?: unknown }).text
224
+ return event.kind === 'prompt' && typeof text === 'string' ? { ...event, text: redactLocalPaths(text) } : event
225
+ }
226
+
216
227
  agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', async (req, res) => {
217
228
  res.set('Cache-Control', 'private, no-store')
218
229
 
@@ -331,6 +342,11 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
331
342
  lastStamp = stampOf(derived)
332
343
  out = { ...event, ...statusDraftWithDerived(event, derived) }
333
344
  }
345
+ // 7.0.4 final QA: the turn history (`?turns=`) redacts local paths in user turns, and the lens finds the turn on
346
+ // screen by matching this prompt's opening words against them (cos-glasses-app session-conversation.ts
347
+ // findTurnStart). An unredacted prompt with a Mac path never matched, so the lens refetched and showed the turn
348
+ // twice. The prompt leaves redacted the same way; server-side consumers keep the raw event.
349
+ out = clientStreamEvent(out)
334
350
  const idLine = typeof out.cursor === 'number' ? `id: ${out.epoch ?? RING_EPOCH}.${out.cursor}\n` : ''
335
351
  res.write(`${idLine}data: ${JSON.stringify({ seq: ++seq, ...out })}\n\n`)
336
352
  } catch {