@gotcos/glasses-server 6.99.25 → 6.99.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -166,7 +166,15 @@ export interface MediaRecord {
|
|
|
166
166
|
textPath?: string
|
|
167
167
|
derivativePaths?: string[]
|
|
168
168
|
bytes: number
|
|
169
|
+
/** SHA-256 of the bytes stored NOW. The x265 pass replaces it with the compressed file's hash. */
|
|
169
170
|
sha256: string
|
|
171
|
+
/**
|
|
172
|
+
* QA 6.99.29: SHA-256 of the bytes as ingested. Set once at publication and never changed by
|
|
173
|
+
* compression, so a queued turn sealed to (id, original hash) still matches after the x265 pass
|
|
174
|
+
* replaced the bytes under the same id. Absent only on a legacy record that was already compressed
|
|
175
|
+
* before this field existed (its compressed hash is then its stable identity: compression never reruns).
|
|
176
|
+
*/
|
|
177
|
+
originalSha256?: string
|
|
170
178
|
lifecycle: MediaLifecycle
|
|
171
179
|
/** Set once the background x265 pass replaced the stored original with a
|
|
172
180
|
* smaller file. Kept so the size drop is explainable and never retried. */
|
|
@@ -281,6 +289,8 @@ function sanitizeVideoCompression(raw: unknown): VideoCompressionRecord | null {
|
|
|
281
289
|
return { label: r.label.slice(0, 64), originalBytes: r.originalBytes, bytes: r.bytes, atMs: r.atMs }
|
|
282
290
|
}
|
|
283
291
|
|
|
292
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/
|
|
293
|
+
|
|
284
294
|
function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
285
295
|
if (!raw || typeof raw !== 'object') return null
|
|
286
296
|
const r = raw as Record<string, unknown>
|
|
@@ -309,6 +319,12 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
309
319
|
? r.derivativePaths.filter(isOwnedPath).slice(0, VIDEO_SUMMARY_FRAMES_MAX)
|
|
310
320
|
: undefined
|
|
311
321
|
const videoCompression = sanitizeVideoCompression(r.videoCompression)
|
|
322
|
+
const sha256 = typeof r.sha256 === 'string' ? r.sha256 : ''
|
|
323
|
+
// A record never compressed still holds its ingested bytes, so its current hash IS the original (backfills
|
|
324
|
+
// records written before originalSha256 existed). A compressed legacy record has no original to recover.
|
|
325
|
+
const originalSha256 = typeof r.originalSha256 === 'string' && SHA256_HEX_RE.test(r.originalSha256)
|
|
326
|
+
? r.originalSha256
|
|
327
|
+
: !videoCompression && SHA256_HEX_RE.test(sha256) ? sha256 : undefined
|
|
312
328
|
return {
|
|
313
329
|
ref,
|
|
314
330
|
storagePath: r.storagePath,
|
|
@@ -316,7 +332,8 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
316
332
|
...(textPath ? { textPath } : {}),
|
|
317
333
|
...(derivativePaths?.length ? { derivativePaths } : {}),
|
|
318
334
|
bytes: typeof r.bytes === 'number' && r.bytes >= 0 ? r.bytes : 0,
|
|
319
|
-
sha256
|
|
335
|
+
sha256,
|
|
336
|
+
...(originalSha256 ? { originalSha256 } : {}),
|
|
320
337
|
lifecycle,
|
|
321
338
|
...(videoCompression ? { videoCompression } : {}),
|
|
322
339
|
contentRemoved: r.contentRemoved === true,
|
|
@@ -690,6 +707,7 @@ export class MediaStore {
|
|
|
690
707
|
...(derivativePaths.length ? { derivativePaths } : {}),
|
|
691
708
|
bytes: prepared.originalBytes,
|
|
692
709
|
sha256,
|
|
710
|
+
originalSha256: sha256,
|
|
693
711
|
lifecycle: 'staged',
|
|
694
712
|
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
695
713
|
...(input.videoUploadId ? { videoUploadId: input.videoUploadId } : {}),
|
|
@@ -857,12 +875,14 @@ export class MediaStore {
|
|
|
857
875
|
throw err
|
|
858
876
|
}
|
|
859
877
|
|
|
878
|
+
const normalizedSha256 = createHash('sha256').update(normalized.normalized).digest('hex')
|
|
860
879
|
const record: MediaRecord = {
|
|
861
880
|
ref,
|
|
862
881
|
storagePath: join('assets', id, 'original-normalized.jpg'),
|
|
863
882
|
thumbPath: join('assets', id, 'thumb.jpg'),
|
|
864
883
|
bytes: normalized.normalized.length,
|
|
865
|
-
sha256:
|
|
884
|
+
sha256: normalizedSha256,
|
|
885
|
+
originalSha256: normalizedSha256,
|
|
866
886
|
lifecycle: 'staged',
|
|
867
887
|
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
868
888
|
createdAtMs: now,
|
|
@@ -1109,6 +1129,25 @@ export class MediaStore {
|
|
|
1109
1129
|
return { record: rec, path: content.path }
|
|
1110
1130
|
}
|
|
1111
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* QA 6.99.29: the content identity a queued turn's files are sealed to: the hash of the bytes as
|
|
1134
|
+
* ingested (`originalSha256`), stable across the x265 pass that rewrites a video under the same id.
|
|
1135
|
+
* The stored bytes are still hashed, and must be what the index says is stored (the compressed or
|
|
1136
|
+
* the original file): anything else returns the hash of what is really there, so a file swapped
|
|
1137
|
+
* under the id no longer matches its seal. Throws as resolveUsable does.
|
|
1138
|
+
*/
|
|
1139
|
+
async contentIdentitySha256(id: string, clientQueueItemId?: string): Promise<string> {
|
|
1140
|
+
const { path } = this.resolveUsable(id, clientQueueItemId)
|
|
1141
|
+
const actual = await sha256OfFile(path)
|
|
1142
|
+
// Read the record AFTER hashing: a compression that swapped the bytes meanwhile has updated it.
|
|
1143
|
+
const rec = this.getRecord(id)
|
|
1144
|
+
if (!rec) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
|
|
1145
|
+
const original = rec.originalSha256 && SHA256_HEX_RE.test(rec.originalSha256) ? rec.originalSha256 : null
|
|
1146
|
+
const current = SHA256_HEX_RE.test(rec.sha256) ? rec.sha256 : null
|
|
1147
|
+
if (actual === current || actual === original) return original ?? current ?? actual
|
|
1148
|
+
return actual
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1112
1151
|
/** Resolve private model derivatives after lifecycle/ownership validation. */
|
|
1113
1152
|
resolveModelDerivatives(
|
|
1114
1153
|
id: string,
|
|
@@ -91,6 +91,24 @@
|
|
|
91
91
|
// while Miles is at the desk is still `{}` at once (`desk_active`); every path still ends.
|
|
92
92
|
// Every request answered `{}` is logged with why, the presence age and the window.
|
|
93
93
|
//
|
|
94
|
+
// COS Control Web joins the away hold, never the liveness (6.99.26, Miles 11/07: "Away rule, like
|
|
95
|
+
// phone"; he answers from wherever he is, sometimes the Mac itself through Screens from the phone).
|
|
96
|
+
// A browser poll `?client=web&hold=1&presenceAgeMs=<int>`, the ms since a touch or a key while the
|
|
97
|
+
// page was in front, is its own presence (`webPresenceAgeMs`, lib/client-liveness), read here in
|
|
98
|
+
// exactly two places: it can make a request away (so held up to the away hold while the desk is idle,
|
|
99
|
+
// and answerable from the web), and it keeps an away-held request through the re-check while it is
|
|
100
|
+
// recent. It never makes a client live: not the 6.59.0 liveness, not a `hold=1` client, not a
|
|
101
|
+
// connected client for a stamped question. So with the away hold off, a hook that waits no longer than
|
|
102
|
+
// 120 s, a stale touch (past the same recent window), or a desk in use, the web holds nothing and the
|
|
103
|
+
// Mac's own pop-up shows at once, as Miles chose for an open tab in 6.99.13.
|
|
104
|
+
// QA round 1 (6.99.26): the web touch admits only what nothing else would (`webOnly`), and changes
|
|
105
|
+
// nothing about a request a glasses or phone client admits: same deadline, flag and hand-back as 6.99.25,
|
|
106
|
+
// and it never keeps one through the re-check. A web-only STAMPED question needs the desk idle at admit
|
|
107
|
+
// (only Claude Desktop is proven to keep its own prompt up while the hook waits, D1) and goes back to the
|
|
108
|
+
// Mac the moment the desk is used, like any away-held request. A touch from the Mac itself never counts
|
|
109
|
+
// (routes/permission-broker.ts requestFromThisMac), and `COS_WEB_AWAY_HOLD=0` turns the web part off
|
|
110
|
+
// alone (`webAwayHoldEnabled`).
|
|
111
|
+
//
|
|
94
112
|
// NEVER past the hook's own wait. The hold is clamped to `holdCeilingS`: the PermissionRequest
|
|
95
113
|
// `timeout` in the Claude settings file (read, not assumed) and the wait the hook stamped on
|
|
96
114
|
// THIS request (`hookWaitS`, 6.60.0 script), whichever is less, minus 10 s; settings that
|
|
@@ -129,6 +147,16 @@ import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signa
|
|
|
129
147
|
|
|
130
148
|
export type BrokerMode = 'off' | 'questions' | 'all'
|
|
131
149
|
|
|
150
|
+
/**
|
|
151
|
+
* 6.99.26 (QA round 1): `COS_WEB_AWAY_HOLD`: `0`/`false`/`off` turns off COS Control Web's part of the
|
|
152
|
+
* away hold alone, so it can be rolled back without touching the glasses and phone. Default on; read per
|
|
153
|
+
* request.
|
|
154
|
+
*/
|
|
155
|
+
export function webAwayHoldEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
156
|
+
const raw = (env.COS_WEB_AWAY_HOLD ?? '').trim().toLowerCase()
|
|
157
|
+
return !(raw === '0' || raw === 'false' || raw === 'off')
|
|
158
|
+
}
|
|
159
|
+
|
|
132
160
|
/**
|
|
133
161
|
* `COS_PERMISSION_BROKER`: `0`/`false`/`off` answers `{}` to everything (the kill switch,
|
|
134
162
|
* no reinstall needed); `questions` brokers AskUserQuestion only; unset or anything else
|
|
@@ -716,6 +744,9 @@ export function approvalCard(toolName: string, toolInput: Record<string, unknown
|
|
|
716
744
|
// ---------------------------------------------------------------------------
|
|
717
745
|
|
|
718
746
|
export type BrokerItemKind = 'question' | 'approval'
|
|
747
|
+
/** 6.99.27: the door an answer came through: the Even app with the API token, or COS Control Web's browser session. */
|
|
748
|
+
export type AnswerSurface = 'app' | 'web'
|
|
749
|
+
|
|
719
750
|
export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
|
|
720
751
|
export type BrokerFastPath =
|
|
721
752
|
| 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
|
|
@@ -771,6 +802,14 @@ export interface PermissionBrokerDeps {
|
|
|
771
802
|
* lib/client-liveness). Absent: null.
|
|
772
803
|
*/
|
|
773
804
|
presenceAgeMs?(): number | null
|
|
805
|
+
/**
|
|
806
|
+
* 6.99.26: how long ago COS Control Web's newest touch or key was (`webPresenceAgeMs`,
|
|
807
|
+
* lib/client-liveness), or null when none since boot. Read for the away hold only, never as a live
|
|
808
|
+
* client. Absent: null (the web holds nothing).
|
|
809
|
+
*/
|
|
810
|
+
webPresenceAgeMs?(): number | null
|
|
811
|
+
/** 6.99.26 (QA round 1): the web part's own switch (`webAwayHoldEnabled`). Absent: on. */
|
|
812
|
+
webAwayHold?(): boolean
|
|
774
813
|
/**
|
|
775
814
|
* 6.60.0: how recent that presence must be for the away hold, in ms
|
|
776
815
|
* (`permissionBrokerAwayRecentClientMs`), never below the 30 s live window. Absent: 30 s.
|
|
@@ -810,6 +849,10 @@ export interface HoldFacts {
|
|
|
810
849
|
recentWindowS: number
|
|
811
850
|
/** 6.60.0: why this request is not away-held, or null when it is. */
|
|
812
851
|
notAwayReason: NotAwayReason | null
|
|
852
|
+
/** 6.99.26: held for COS Control Web's touch alone; absent for every other request. */
|
|
853
|
+
webOnly?: true
|
|
854
|
+
/** 6.99.26: the age of that touch, present only with `webOnly`. */
|
|
855
|
+
webPresenceAgeMs?: number
|
|
813
856
|
}
|
|
814
857
|
|
|
815
858
|
/**
|
|
@@ -841,6 +884,8 @@ export interface ParkRequest {
|
|
|
841
884
|
awayHold?: boolean
|
|
842
885
|
askHold?: boolean
|
|
843
886
|
lensVisible?: boolean
|
|
887
|
+
/** 6.99.26: admitted for COS Control Web's recent touch alone (see THE AWAY HOLD). */
|
|
888
|
+
webOnly?: boolean
|
|
844
889
|
hold?: HoldFacts
|
|
845
890
|
}
|
|
846
891
|
|
|
@@ -874,6 +919,8 @@ interface BrokerItem {
|
|
|
874
919
|
awayHold: boolean
|
|
875
920
|
askHold: boolean
|
|
876
921
|
lensVisible: boolean
|
|
922
|
+
/** 6.99.26: admitted for the web touch alone: kept only while a touch is recent, and the desk hands it back. */
|
|
923
|
+
webOnly: boolean
|
|
877
924
|
toolUseId: string | null
|
|
878
925
|
questionFingerprint: string
|
|
879
926
|
/**
|
|
@@ -1024,6 +1071,13 @@ function sessionMovedPast(item: Pick<BrokerItem, 'kind' | 'fingerprint' | 'toolN
|
|
|
1024
1071
|
return false
|
|
1025
1072
|
}
|
|
1026
1073
|
|
|
1074
|
+
/** A presence age from a dep: a finite number of ms at or above 0, else null (absent, throwing or nonsense). */
|
|
1075
|
+
function readAgeMs(read: () => number | null): number | null {
|
|
1076
|
+
let ageMs: number | null = null
|
|
1077
|
+
try { ageMs = read() } catch { ageMs = null }
|
|
1078
|
+
return typeof ageMs === 'number' && Number.isFinite(ageMs) && ageMs >= 0 ? ageMs : null
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1027
1081
|
export class PermissionBroker {
|
|
1028
1082
|
private readonly deps: PermissionBrokerDeps
|
|
1029
1083
|
private readonly items = new Map<string, BrokerItem>()
|
|
@@ -1058,7 +1112,7 @@ export class PermissionBroker {
|
|
|
1058
1112
|
}
|
|
1059
1113
|
|
|
1060
1114
|
/** A request answered `{}` without being parked, counted by why. */
|
|
1061
|
-
noteFastPath(reason: BrokerFastPath, context: { notAway?: NotAwayReason; presence?: { ageMs: number | null; windowMs: number }; clientLive?: boolean } = {}): { ok: false; reason: BrokerFastPath } {
|
|
1115
|
+
noteFastPath(reason: BrokerFastPath, context: { notAway?: NotAwayReason; presence?: { ageMs: number | null; windowMs: number; webAgeMs?: number | null }; clientLive?: boolean } = {}): { ok: false; reason: BrokerFastPath } {
|
|
1062
1116
|
const key = camelKey(reason)
|
|
1063
1117
|
this.fastPaths[key] = (this.fastPaths[key] ?? 0) + 1
|
|
1064
1118
|
this.lastFastPath = reason
|
|
@@ -1068,7 +1122,9 @@ export class PermissionBroker {
|
|
|
1068
1122
|
let presence = context.presence
|
|
1069
1123
|
if (!presence) { try { presence = this.presence(this.deps.now()) } catch { presence = undefined } }
|
|
1070
1124
|
const age = presence?.ageMs === null || presence?.ageMs === undefined ? 'null' : String(Math.round(presence.ageMs))
|
|
1071
|
-
|
|
1125
|
+
// 6.99.26: the web's touch age only once the web has reported one, so every older line reads as before.
|
|
1126
|
+
const web = typeof presence?.webAgeMs === 'number' ? ` webPresenceAgeMs=${Math.round(presence.webAgeMs)}` : ''
|
|
1127
|
+
this.log(`fast path ${reason}${context.notAway ? ` not_away=${context.notAway}` : ''} presenceAgeMs=${age} windowMs=${presence?.windowMs ?? 'null'}${web}${context.clientLive === undefined ? '' : ` clientLive=${context.clientLive}`}`)
|
|
1072
1128
|
return { ok: false, reason }
|
|
1073
1129
|
}
|
|
1074
1130
|
|
|
@@ -1110,16 +1166,19 @@ export class PermissionBroker {
|
|
|
1110
1166
|
* - `holdLive`: a `hold=1` client polled within the live window AND its presence is
|
|
1111
1167
|
* recent, so it counts as a live client the way an older client's poll does. A
|
|
1112
1168
|
* connected lens nobody touches never counts (QA round 1, Skeptic W1).
|
|
1169
|
+
* - 6.99.26: `webAgeMs` and `webRecent`, COS Control Web's touch on its own: for the away
|
|
1170
|
+
* hold only, so it is never part of `recent` or `holdLive`.
|
|
1113
1171
|
*/
|
|
1114
|
-
private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number } {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1172
|
+
private presence(now: number): { ageMs: number | null; recent: boolean; holdLive: boolean; windowMs: number; webAgeMs: number | null; webRecent: boolean } {
|
|
1173
|
+
const ageMs = readAgeMs(() => this.deps.presenceAgeMs?.() ?? null)
|
|
1174
|
+
let webOn = true
|
|
1175
|
+
try { webOn = this.deps.webAwayHold?.() !== false } catch { webOn = false }
|
|
1176
|
+
const webAgeMs = webOn ? readAgeMs(() => this.deps.webPresenceAgeMs?.() ?? null) : null
|
|
1118
1177
|
const windowMs = this.recentWindowMs()
|
|
1119
1178
|
const recent = ageMs !== null && ageMs <= windowMs
|
|
1120
1179
|
let holdPollAt: number | null = null
|
|
1121
1180
|
try { holdPollAt = this.deps.lastHoldPollAt?.() ?? null } catch { holdPollAt = null }
|
|
1122
|
-
return { ageMs, recent, holdLive: recent && this.polledWithin(holdPollAt, now), windowMs }
|
|
1181
|
+
return { ageMs, recent, holdLive: recent && this.polledWithin(holdPollAt, now), windowMs, webAgeMs, webRecent: webAgeMs !== null && webAgeMs <= windowMs }
|
|
1123
1182
|
}
|
|
1124
1183
|
|
|
1125
1184
|
/** 6.60.0: the installed hook timeout, or null when absent or unreadable. */
|
|
@@ -1207,13 +1266,19 @@ export class PermissionBroker {
|
|
|
1207
1266
|
// window (Miles, 19:54; QA round 1).
|
|
1208
1267
|
const switchOn = this.awayHoldMs() > 0
|
|
1209
1268
|
const ceilingAllowsAway = this.holdMs(facts.hookWaitS, false).ceilingAllowsAway
|
|
1210
|
-
|
|
1269
|
+
// 6.60.0 to 6.99.25, unchanged: what a live client and a wearer's presence allow.
|
|
1270
|
+
const deviceAway = switchOn && ceilingAllowsAway && (askHold || presence.recent)
|
|
1271
|
+
const deviceAdmits = askHold ? clientLive || presence.recent : clientLive || deviceAway
|
|
1272
|
+
// 6.99.26 (QA round 1): a recent touch on COS Control Web admits only what nothing else would, for the away
|
|
1273
|
+
// hold alone. A request a glasses or phone client admits keeps its 6.99.25 deadline, flag and hand-back.
|
|
1274
|
+
const webOnly = !deviceAdmits && switchOn && ceilingAllowsAway && presence.webRecent
|
|
1275
|
+
const away = deviceAway || webOnly
|
|
1211
1276
|
const notAwayReason: NotAwayReason | null = away ? null
|
|
1212
1277
|
: !switchOn ? 'hold_off'
|
|
1213
1278
|
: !ceilingAllowsAway ? 'ceiling_at_base'
|
|
1214
1279
|
: presence.ageMs === null ? 'no_hold_client'
|
|
1215
1280
|
: 'stale_presence'
|
|
1216
|
-
if (askHold && !
|
|
1281
|
+
if (askHold && !deviceAdmits && !webOnly) return this.noteFastPath('no_client', { presence, clientLive })
|
|
1217
1282
|
const hold = this.holdMs(facts.hookWaitS, away)
|
|
1218
1283
|
// With it, a request is held whether or not a client is polling right now, so a lens or
|
|
1219
1284
|
// phone that comes back can still answer it. Without it, no client is `{}`, as in 6.59.0.
|
|
@@ -1228,6 +1293,10 @@ export class PermissionBroker {
|
|
|
1228
1293
|
try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null }
|
|
1229
1294
|
if (!askHold && idle === null) return this.noteFastPath('desk_unknown')
|
|
1230
1295
|
if (!askHold && idle! < this.deskIdleThreshold()) return this.noteFastPath('desk_active', { notAway: 'desk_active', presence, clientLive })
|
|
1296
|
+
// 6.99.26 (QA round 1): a stamped question the web touch alone admits needs the desk idle now. Only Claude
|
|
1297
|
+
// Desktop is proven to keep its own prompt up while the hook waits (D1); at the desk, the Mac answers.
|
|
1298
|
+
if (webOnly && idle === null) return this.noteFastPath('desk_unknown')
|
|
1299
|
+
if (webOnly && idle! < this.deskIdleThreshold()) return this.noteFastPath('desk_active', { notAway: 'desk_active', presence, clientLive })
|
|
1231
1300
|
const mode = this.deps.mode()
|
|
1232
1301
|
if (mode === 'off') return this.noteFastPath('broker_off')
|
|
1233
1302
|
if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
|
|
@@ -1258,8 +1327,9 @@ export class PermissionBroker {
|
|
|
1258
1327
|
presenceAgeMs: presence.ageMs === null ? null : Math.round(presence.ageMs),
|
|
1259
1328
|
recentWindowS: Math.round(presence.windowMs / 1000),
|
|
1260
1329
|
notAwayReason,
|
|
1330
|
+
...(webOnly ? { webOnly: true as const, webPresenceAgeMs: Math.round(presence.webAgeMs ?? 0) } : {}),
|
|
1261
1331
|
}
|
|
1262
|
-
return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), hold: holdFacts } }
|
|
1332
|
+
return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt, awayHold, askHold, lensVisible: !askHold || (idle !== null && idle >= this.deskIdleThreshold()), ...(webOnly ? { webOnly: true } : {}), hold: holdFacts } }
|
|
1263
1333
|
}
|
|
1264
1334
|
|
|
1265
1335
|
/** Hold the hook's response until exactly one resolution. Returns the item id. */
|
|
@@ -1289,6 +1359,7 @@ export class PermissionBroker {
|
|
|
1289
1359
|
channel,
|
|
1290
1360
|
timer: null,
|
|
1291
1361
|
awayHold: request.awayHold === true,
|
|
1362
|
+
webOnly: request.webOnly === true,
|
|
1292
1363
|
askHold: request.askHold === true,
|
|
1293
1364
|
lensVisible: request.lensVisible !== false,
|
|
1294
1365
|
toolUseId: facts.toolUseId ?? null,
|
|
@@ -1438,7 +1509,7 @@ export class PermissionBroker {
|
|
|
1438
1509
|
* clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
|
|
1439
1510
|
* it is the safe direction, since it only shows the dialog that would have shown anyway.
|
|
1440
1511
|
*/
|
|
1441
|
-
answer(id: string, body: unknown): AnswerResult {
|
|
1512
|
+
answer(id: string, body: unknown, by: AnswerSurface = 'app'): AnswerResult {
|
|
1442
1513
|
const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
|
|
1443
1514
|
if (!item) return { status: 404, body: { error: 'not_found' } }
|
|
1444
1515
|
const input = isRecord(body) ? body : {}
|
|
@@ -1490,7 +1561,9 @@ export class PermissionBroker {
|
|
|
1490
1561
|
item.timer = null
|
|
1491
1562
|
this.counters.answered++
|
|
1492
1563
|
try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
|
|
1493
|
-
|
|
1564
|
+
// 6.99.27: who answered, by the door it came through: the Even app (lens or phone, the API token) or COS Control
|
|
1565
|
+
// Web (the browser session). A web answer once logged as glasses_won.
|
|
1566
|
+
if (item.kind === 'question') this.log(`${by === 'web' ? 'web_won' : 'glasses_won'} ${item.id}`)
|
|
1494
1567
|
this.log(`answered ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
|
|
1495
1568
|
this.prune()
|
|
1496
1569
|
return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
|
|
@@ -1631,11 +1704,14 @@ export class PermissionBroker {
|
|
|
1631
1704
|
// 6.60.0: an item held by the away hold outlives the client going quiet, but only while
|
|
1632
1705
|
// the wearer's latest presence is still within the window (QA round 1, Skeptic W2: a
|
|
1633
1706
|
// single glance must not hold a question 10 more minutes). The desk below still hands
|
|
1634
|
-
// it back.
|
|
1635
|
-
|
|
1707
|
+
// it back. 6.99.26 (QA round 1): a recent web touch keeps only what it admitted; a glasses or
|
|
1708
|
+
// phone hold reads exactly as in 6.99.25.
|
|
1709
|
+
this.settleAll('no_client', item => item.webOnly
|
|
1710
|
+
? !(presence.recent || presence.webRecent)
|
|
1711
|
+
: !item.askHold && (!item.awayHold || !presence.recent))
|
|
1636
1712
|
if (this.pendingCount() === 0) return
|
|
1637
1713
|
}
|
|
1638
|
-
if (![...this.items.values()].some(item => item.state === 'pending' && (!item.askHold || !item.lensVisible))) return
|
|
1714
|
+
if (![...this.items.values()].some(item => item.state === 'pending' && (!item.askHold || !item.lensVisible || item.webOnly))) return
|
|
1639
1715
|
if (this.pollInFlight) return
|
|
1640
1716
|
this.pollInFlight = true
|
|
1641
1717
|
let idle: number | null = null
|
|
@@ -1643,7 +1719,7 @@ export class PermissionBroker {
|
|
|
1643
1719
|
if (idle === null) {
|
|
1644
1720
|
this.counters.deskUnreadable++
|
|
1645
1721
|
this.deskUnreadableStreak++
|
|
1646
|
-
if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk', item => !item.askHold)
|
|
1722
|
+
if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk', item => !item.askHold || item.webOnly)
|
|
1647
1723
|
return
|
|
1648
1724
|
}
|
|
1649
1725
|
this.deskUnreadableStreak = 0
|
|
@@ -1656,7 +1732,8 @@ export class PermissionBroker {
|
|
|
1656
1732
|
}
|
|
1657
1733
|
return
|
|
1658
1734
|
}
|
|
1659
|
-
|
|
1735
|
+
// 6.99.26 (QA round 1): a web-only stamped question goes back to the Mac as any away-held request does.
|
|
1736
|
+
this.settleAll('handed_to_desk', item => !item.askHold || item.webOnly)
|
|
1660
1737
|
}
|
|
1661
1738
|
|
|
1662
1739
|
private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client', only: (item: BrokerItem) => boolean = () => true): void {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { cancelVoidsOpenTurn, type ThreadCancel } from './session-cancel.js'
|
|
19
19
|
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
20
|
+
import { stripG2TurnFooter, withoutTurnFooter } from './turn-footer.js'
|
|
20
21
|
import type { TurnAttemptLookup } from './agent-session-binding-registry.js'
|
|
21
22
|
import type { QueuedThreadTurn } from './thread-turn-queue.js'
|
|
22
23
|
|
|
@@ -215,13 +216,29 @@ function textOf(content: unknown): string | null {
|
|
|
215
216
|
return parts.length > 0 ? parts.join('\n') : null
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* The words, as typed or out of Claude Code's peer wrapper, equal the prompt exactly.
|
|
221
|
+
* 6.99.29: a glasses, phone or web turn reaches the session with one trailing verify footer line
|
|
222
|
+
* (lib/g2-turn-provenance.ts) that the queue row and the turns route never held, so that one
|
|
223
|
+
* line is taken off the transcript's text before the exact comparison. Nothing else is.
|
|
224
|
+
*/
|
|
219
225
|
function sameWords(text: string | null, want: string): boolean {
|
|
220
226
|
if (text === null) return false
|
|
221
227
|
if (text.trim() === want) return true
|
|
228
|
+
if (footedWords(text, want)) return true
|
|
222
229
|
return unwrapPeerMessage(text) === want
|
|
223
230
|
}
|
|
224
231
|
|
|
232
|
+
/** A row that carries a verify footer, as typed or inside the peer wrapper, whose words are `want`. */
|
|
233
|
+
function footedWords(text: string, want: string): boolean {
|
|
234
|
+
for (const candidate of [text, unwrapPeerMessage(text)]) {
|
|
235
|
+
if (typeof candidate !== 'string') continue
|
|
236
|
+
const stripped = stripG2TurnFooter(candidate)
|
|
237
|
+
if (stripped.turnId !== null && stripped.text.trim() === want) return true
|
|
238
|
+
}
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
|
|
225
242
|
/**
|
|
226
243
|
* Does this transcript tail show the session took exactly these words, at or after
|
|
227
244
|
* `sinceMs`? WRAPPER-AWARE AND EXACT: the whole prompt, trimmed, either as typed or inside
|
|
@@ -286,7 +303,7 @@ export function makeDeliveryEvidence(deps: DeliveryEvidenceDeps): (turn: QueuedT
|
|
|
286
303
|
let codexQueueMatch = false
|
|
287
304
|
if (!transcriptMatch && turn.provider === 'codex' && typeof deps.codexQueueTexts === 'function') {
|
|
288
305
|
const texts = await deps.codexQueueTexts(turn.threadId)
|
|
289
|
-
codexQueueMatch = texts.some(text => typeof text === 'string' && text.trim() === turn.prompt.trim())
|
|
306
|
+
codexQueueMatch = texts.some(text => typeof text === 'string' && withoutTurnFooter(text).trim() === turn.prompt.trim())
|
|
290
307
|
}
|
|
291
308
|
return reconcileVerdict({ row: turn, ledgerOutcome, transcriptMatch, codexQueueMatch, attempt })
|
|
292
309
|
} catch {
|
|
@@ -39,6 +39,7 @@ import { atomicWriteFileSync } from './atomic-fs.js'
|
|
|
39
39
|
import { dataPath } from './data-dir.js'
|
|
40
40
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
41
41
|
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
42
|
+
import { stripG2TurnFooter, withoutTurnFooter } from './turn-footer.js'
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
|
|
@@ -263,14 +264,27 @@ export function normalizePromptWords(text: unknown): string {
|
|
|
263
264
|
export function isDeliveredPrompt(prompt: unknown, delivered: string, options: { verbatim?: boolean } = {}): boolean {
|
|
264
265
|
if (options.verbatim === true) {
|
|
265
266
|
// 6.62.0: Codex's queued turn arrives as its own text. No wrapper to take off.
|
|
267
|
+
// 6.99.29: a glasses, phone or web turn arrives with one trailing verify footer line the
|
|
268
|
+
// route's own `prompt` never held; that line, and only that line, comes off first.
|
|
266
269
|
const want = typeof delivered === 'string' ? delivered.trim() : ''
|
|
267
|
-
return want.length > 0 && typeof prompt === 'string' && prompt.trim() === want
|
|
270
|
+
return want.length > 0 && typeof prompt === 'string' && withoutTurnFooter(prompt).trim() === want
|
|
268
271
|
}
|
|
269
272
|
const want = normalizePromptWords(delivered)
|
|
270
273
|
if (want.length === 0 || typeof prompt !== 'string') return false
|
|
274
|
+
if (footedPromptIs(prompt, want)) return true
|
|
271
275
|
return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
/** 6.99.29: a prompt that carries a verify footer, as typed or inside the peer wrapper, whose words are `want`. */
|
|
279
|
+
function footedPromptIs(prompt: string, want: string): boolean {
|
|
280
|
+
for (const candidate of [prompt, unwrapPeerMessage(prompt)]) {
|
|
281
|
+
if (typeof candidate !== 'string') continue
|
|
282
|
+
const stripped = stripG2TurnFooter(candidate)
|
|
283
|
+
if (stripped.turnId !== null && normalizePromptWords(stripped.text) === want) return true
|
|
284
|
+
}
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
274
288
|
/**
|
|
275
289
|
* A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
|
|
276
290
|
* the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// carrying its real name in `target`, so a provider adding a tool tomorrow degrades to
|
|
18
18
|
// a readable line instead of an unrenderable one.
|
|
19
19
|
|
|
20
|
+
import { withoutTurnFooter } from './turn-footer.js'
|
|
21
|
+
|
|
20
22
|
/** Closed set. A provider tool name that is not in the table maps to `other`. */
|
|
21
23
|
export type SessionStreamVerb = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'task' | 'other'
|
|
22
24
|
|
|
@@ -557,6 +559,8 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
|
|
|
557
559
|
let text = parts.join(' ')
|
|
558
560
|
// A live Continue's words, out of the peer wrapper (see `unwrapPeerMessage`).
|
|
559
561
|
text = unwrapPeerMessage(text) ?? text
|
|
562
|
+
// 6.99.29: the one trailing verify footer line of a glasses, phone or web turn is for the agent.
|
|
563
|
+
text = withoutTurnFooter(text)
|
|
560
564
|
// Wrapper blocks out, whole. A partial strip would leave the tag names on the lens.
|
|
561
565
|
text = text.replace(/<(system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g, ' ')
|
|
562
566
|
const flat = oneLine(text, PROMPT_MAX_CHARS)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.99.26 candidate: who a session has working for it, for the Today summary (lib/today-brief.ts). Read-only display
|
|
3
|
+
* evidence: never imported by an attach, queue or write gate.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code: nothing on the server read sub-agent NAMES before this (the hooks give counts only:
|
|
6
|
+
* session-work-observation.ts). This reads the session's own transcript TAIL (the last 768 KB) and follows each Agent
|
|
7
|
+
* or Task tool call:
|
|
8
|
+
* - the call gives the name (`name`, else `subagent_type`) and the one-line task (`description`);
|
|
9
|
+
* - its tool result says how it started: `async_launched` and `teammate_spawned` are running, any other result is a
|
|
10
|
+
* finished foreground agent, an error result is failed;
|
|
11
|
+
* - a `<task-notification>` naming the call's tool-use id ends a background agent (completed, failed or killed);
|
|
12
|
+
* - a `<teammate-message teammate_id="name">` moves a teammate: an `idle_notification` makes it idle, a shutdown
|
|
13
|
+
* approval makes it done, anything else is it reporting; a SendMessage to it from the session makes it running again.
|
|
14
|
+
* Agents spawned before the tail are not seen; `partial` says the read started mid-file.
|
|
15
|
+
*
|
|
16
|
+
* Codex: codex-subagent-activity.ts readCodexSubagentDetails (the same spawn-edge table and child lifecycle reader the
|
|
17
|
+
* list already uses for its counts).
|
|
18
|
+
*
|
|
19
|
+
* Opens are non-blocking and refuse symlinks (O_NONBLOCK | O_NOFOLLOW, then a regular-file check); a read is memoized
|
|
20
|
+
* on mtime and size like readSessionActivity, and the memo holds only names, tasks, states and times.
|
|
21
|
+
*/
|
|
22
|
+
import { constants } from 'node:fs'
|
|
23
|
+
import { open } from 'node:fs/promises'
|
|
24
|
+
|
|
25
|
+
export type SubagentStatus = 'running' | 'idle' | 'done' | 'failed' | 'unknown'
|
|
26
|
+
export interface SubagentRow { name: string; task: string; status: SubagentStatus; at: number }
|
|
27
|
+
export interface SessionSubagents { rows: SubagentRow[]; running: number; idle: number; done: number; failed: number; total: number; partial: boolean }
|
|
28
|
+
|
|
29
|
+
export const SUBAGENTS_PER_SESSION = 6
|
|
30
|
+
export const CLAUDE_SUBAGENT_TAIL_BYTES = 768 * 1024
|
|
31
|
+
const MAX_TRACKED = 256
|
|
32
|
+
const MEMO_MAX = 64
|
|
33
|
+
const NAME_MAX = 60
|
|
34
|
+
const TASK_MAX = 160
|
|
35
|
+
|
|
36
|
+
const clip = (v: unknown, max: number) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
|
|
37
|
+
const AGENT_TOOLS = new Set(['Agent', 'Task'])
|
|
38
|
+
|
|
39
|
+
interface Tracked extends SubagentRow { team: boolean }
|
|
40
|
+
|
|
41
|
+
/** The plain text of a user line or a queued message: a string, or its text blocks joined. */
|
|
42
|
+
function textOf(content: unknown): string {
|
|
43
|
+
if (typeof content === 'string') return content
|
|
44
|
+
if (!Array.isArray(content)) return ''
|
|
45
|
+
return content.map(b => (b && typeof b === 'object' && (b as any).type === 'text' && typeof (b as any).text === 'string' ? (b as any).text : '')).join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NOTIFICATION = /<task-notification>[\s\S]*?<tool-use-id>([^<\s]{1,120})<\/tool-use-id>[\s\S]*?<status>([a-z_]{1,24})<\/status>/g
|
|
49
|
+
const TEAMMATE = /<teammate-message\s+teammate_id="([^"]{1,80})"[^>]*>([\s\S]{0,600})/g
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sub-agents from a transcript's tail, most recently moved first, at most `limit` rows, with counts over every one
|
|
53
|
+
* the tail shows. `startsMidFile` drops the first (partial) line.
|
|
54
|
+
*/
|
|
55
|
+
export function parseClaudeSubagents(text: string, startsMidFile = false, limit = SUBAGENTS_PER_SESSION): SessionSubagents {
|
|
56
|
+
// A read that starts mid-file has a cut first line; it fails to parse and is skipped like any bad line. It is not
|
|
57
|
+
// dropped blindly: a tail that starts exactly on a line boundary would lose a whole event. `partial` says so either way.
|
|
58
|
+
const lines = text.split('\n')
|
|
59
|
+
const byTool = new Map<string, Tracked>()
|
|
60
|
+
const byName = new Map<string, Tracked>()
|
|
61
|
+
const move = (t: Tracked, status: SubagentStatus | null, at: number) => {
|
|
62
|
+
if (at < t.at) return
|
|
63
|
+
t.at = at
|
|
64
|
+
if (status) t.status = status
|
|
65
|
+
}
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (!line || line.length > 4_000_000) continue
|
|
68
|
+
let row: any
|
|
69
|
+
try { row = JSON.parse(line) } catch { continue }
|
|
70
|
+
const at = Date.parse(row?.timestamp) || 0
|
|
71
|
+
if (row?.type === 'assistant' && Array.isArray(row.message?.content)) {
|
|
72
|
+
for (const b of row.message.content) {
|
|
73
|
+
if (b?.type !== 'tool_use' || typeof b.id !== 'string') continue
|
|
74
|
+
const input = b.input && typeof b.input === 'object' ? b.input : {}
|
|
75
|
+
if (AGENT_TOOLS.has(b.name)) {
|
|
76
|
+
if (byTool.size >= MAX_TRACKED) continue
|
|
77
|
+
const name = clip(input.name, NAME_MAX) || clip(input.subagent_type, NAME_MAX) || 'agent'
|
|
78
|
+
const t: Tracked = { name, task: clip(input.description, TASK_MAX), status: 'running', at, team: typeof input.name === 'string' && !!input.name.trim() }
|
|
79
|
+
byTool.set(b.id, t)
|
|
80
|
+
if (t.team) byName.set(name, t)
|
|
81
|
+
} else if (b.name === 'SendMessage' && typeof input.to === 'string') {
|
|
82
|
+
const t = byName.get(input.to.trim())
|
|
83
|
+
if (t && t.status !== 'done' && t.status !== 'failed') move(t, 'running', at)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (row?.type === 'user' && Array.isArray(row.message?.content)) {
|
|
89
|
+
for (const b of row.message.content) {
|
|
90
|
+
if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string') continue
|
|
91
|
+
const t = byTool.get(b.tool_use_id)
|
|
92
|
+
if (!t) continue
|
|
93
|
+
const result = row.toolUseResult && typeof row.toolUseResult === 'object' ? row.toolUseResult : {}
|
|
94
|
+
if (b.is_error === true) move(t, 'failed', at)
|
|
95
|
+
else if (result.status === 'async_launched' || result.status === 'teammate_spawned') move(t, 'running', at)
|
|
96
|
+
else move(t, 'done', at)
|
|
97
|
+
if (result.status === 'teammate_spawned' && typeof result.name === 'string' && result.name.trim()) {
|
|
98
|
+
t.team = true
|
|
99
|
+
byName.set(clip(result.name, NAME_MAX), t)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Notifications arrive as a user line, and as a queued message before it is delivered.
|
|
104
|
+
const text = row?.type === 'user' ? textOf(row.message?.content) : row?.type === 'queue-operation' ? textOf(row.content) : ''
|
|
105
|
+
if (!text || (text.indexOf('<task-notification>') < 0 && text.indexOf('<teammate-message') < 0)) continue
|
|
106
|
+
for (const m of text.matchAll(NOTIFICATION)) {
|
|
107
|
+
const t = byTool.get(m[1]!)
|
|
108
|
+
if (t) move(t, m[2] === 'failed' ? 'failed' : 'done', at)
|
|
109
|
+
}
|
|
110
|
+
for (const m of text.matchAll(TEAMMATE)) {
|
|
111
|
+
const t = byName.get(m[1]!)
|
|
112
|
+
if (!t) continue
|
|
113
|
+
const body = m[2] || ''
|
|
114
|
+
if (/"type"\s*:\s*"shutdown_(?:approved|response)"[\s\S]*?"approve"\s*:\s*true|"type"\s*:\s*"shutdown_approved"/.test(body)) move(t, 'done', at)
|
|
115
|
+
else if (/"type"\s*:\s*"idle_notification"/.test(body)) move(t, 'idle', at)
|
|
116
|
+
else move(t, null, at)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return summarize([...byTool.values()], startsMidFile, limit)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function summarize(all: SubagentRow[], partial: boolean, limit = SUBAGENTS_PER_SESSION): SessionSubagents {
|
|
123
|
+
const out: SessionSubagents = { rows: [], running: 0, idle: 0, done: 0, failed: 0, total: all.length, partial }
|
|
124
|
+
for (const a of all) if (a.status !== 'unknown') out[a.status]++
|
|
125
|
+
// Live ones first, then by when each last moved.
|
|
126
|
+
const rank = (s: SubagentStatus) => (s === 'running' ? 0 : s === 'idle' ? 1 : 2)
|
|
127
|
+
out.rows = [...all].sort((a, b) => rank(a.status) - rank(b.status) || b.at - a.at).slice(0, limit)
|
|
128
|
+
.map(a => ({ name: a.name, task: a.task, status: a.status, at: a.at }))
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const memo = new Map<string, { mtimeMs: number; size: number; value: SessionSubagents }>()
|
|
133
|
+
|
|
134
|
+
/** One Claude transcript's sub-agents, or null when it cannot be read. Never throws. */
|
|
135
|
+
export async function readClaudeSubagents(file: string, tailBytes = CLAUDE_SUBAGENT_TAIL_BYTES): Promise<SessionSubagents | null> {
|
|
136
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null
|
|
137
|
+
try {
|
|
138
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
|
|
139
|
+
const st = await handle.stat()
|
|
140
|
+
if (!st.isFile()) return null
|
|
141
|
+
const hit = memo.get(file)
|
|
142
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value
|
|
143
|
+
const length = Math.min(st.size, tailBytes), start = st.size - length
|
|
144
|
+
const buffer = Buffer.alloc(length)
|
|
145
|
+
const read = await handle.read(buffer, 0, length, start)
|
|
146
|
+
const value = parseClaudeSubagents(buffer.subarray(0, read.bytesRead).toString('utf8'), start > 0)
|
|
147
|
+
if (memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
|
|
148
|
+
memo.set(file, { mtimeMs: st.mtimeMs, size: st.size, value })
|
|
149
|
+
return value
|
|
150
|
+
} catch {
|
|
151
|
+
return null
|
|
152
|
+
} finally {
|
|
153
|
+
await handle?.close().catch(() => {})
|
|
154
|
+
}
|
|
155
|
+
}
|