@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.
- package/.env.example +18 -0
- package/CHANGELOG.md +475 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- 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 +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- 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/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- 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/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- 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 +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- 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 +13 -0
- package/server/routes/agent-session-stream.ts +16 -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/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +79 -9
- package/server/web-control/app.js +1543 -65
- package/server/web-control/control.css +37 -6
- package/server/web-control/index.html +8 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +51 -6
- package/server/web-control/panes/cos-chat.js +268 -16
- package/server/web-control/panes/meetings.css +23 -4
- package/server/web-control/panes/meetings.js +25 -9
- package/server/web-control/panes/memories.css +18 -3
- package/server/web-control/panes/memories.js +130 -50
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +127 -26
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +50 -10
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +49 -18
- package/server/web-control/panes/today-actions.js +217 -50
- package/server/web-control/panes/work.css +11 -1
- package/server/web-control/panes/work.js +10 -2
- 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
|
|
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
|
|
@@ -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
|
})
|
|
@@ -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 {
|