@gotcos/glasses-server 7.0.7 → 7.0.27
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 +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
package/server/index.ts
CHANGED
|
@@ -18,12 +18,20 @@ import { createClientInstanceRouter, isMeetingChunkPost, pairedInstanceFrom } fr
|
|
|
18
18
|
import type { ClientInstanceOwner } from './lib/client-instance-claim.js'
|
|
19
19
|
import { G2TurnLedger, G2TurnProvenance, G2_TURN_LEDGER_FILE, type OriginRequest } from './lib/g2-turn-provenance.js'
|
|
20
20
|
import { createG2TurnsRouter } from './routes/g2-turns.js'
|
|
21
|
+
import { WebKeyAuthority, webKeyAuthorityEnabled } from './lib/web-key-authority.js'
|
|
22
|
+
import { deviceAllowlistFrom, WEB_KEY_AUDIT_FILE, WEB_KEY_NONCES_FILE, WEB_KEYS_FILE, WebKeyEnrollments, WebKeyNonces, WebKeyPackets, WebKeyRegistry } from './lib/web-key-registry.js'
|
|
23
|
+
import { WEB_KEY_SIGNER_PORT, webKeySignerOrigin } from './lib/web-key-origin.js'
|
|
24
|
+
import { createWebKeyRouter } from './routes/web-key.js'
|
|
25
|
+
import { WebKeySignHandoffs } from './lib/web-key-handoff.js'
|
|
26
|
+
import { combinedIntegrityReport, createWebKeySignerHandler } from './lib/web-key-signer-app.js'
|
|
27
|
+
import { WebControlIntegrity, webControlIntegrityEnabled } from './lib/web-control-integrity.js'
|
|
21
28
|
import { getTailscaleNudger, installTailscaleNudger } from './lib/tailscale-nudge.js'
|
|
22
29
|
import { queryRouter } from './routes/query.js'
|
|
23
30
|
import { providerProofRouter } from './routes/provider-proof.js'
|
|
24
31
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
25
32
|
import { sessionIndexRouter } from './routes/session-index.js'
|
|
26
33
|
import { agentSessionsRouter } from './routes/agent-sessions.js'
|
|
34
|
+
import { createSessionOrganizerRouter } from './routes/session-organizer.js'
|
|
27
35
|
import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
28
36
|
import { createLiveEventsRouter, liveEventsEnabled, realLiveSources } from './routes/live-events.js'
|
|
29
37
|
import { LiveEventsHub } from './lib/live-events.js'
|
|
@@ -48,6 +56,9 @@ import {
|
|
|
48
56
|
wirePermissionBrokerToSignals,
|
|
49
57
|
} from './lib/permission-broker.js'
|
|
50
58
|
import { createPermissionBrokerHookRouter, createSessionQuestionsRouter } from './routes/permission-broker.js'
|
|
59
|
+
// X1: the full hold's switches and the engine gate's registry read (kept on their own lines, apart from other lanes' imports).
|
|
60
|
+
import { fullHoldSwitches } from './lib/full-hold.js'
|
|
61
|
+
import { engineForHookProcess, registryStatusForPid } from './lib/full-hold-engine.js'
|
|
51
62
|
import { lastHoldQuestionsPollAt, lastLegacyQuestionsPollAt, lastQuestionsPollAt, presenceAgeMs, webPresenceAgeMs } from './lib/client-liveness.js'
|
|
52
63
|
import {
|
|
53
64
|
createAgentSessionBindingsRouter,
|
|
@@ -124,7 +135,8 @@ import { findPinnedCli, killParakeetChildren, sweepParakeetStaging, sweepStalePa
|
|
|
124
135
|
import { handoffsRouter } from './routes/handoffs.js'
|
|
125
136
|
import { recoveryRouter } from './routes/recovery.js'
|
|
126
137
|
import { promptEditRouter } from './routes/prompt-edit.js'
|
|
127
|
-
import { createTodayBriefRouter, defaultTodayBrief,
|
|
138
|
+
import { createTodayBriefRouter, defaultTodayBrief, readSessionContext } from './routes/today-brief.js'
|
|
139
|
+
import { calendarToday } from './lib/calendar-source.js'
|
|
128
140
|
import { liveCosAskRouter } from './routes/live-cos-ask.js'
|
|
129
141
|
import { meetingAskRouter } from './routes/meeting-ask.js'
|
|
130
142
|
import { meetingAskRuntime } from './lib/meeting-ask-runtime.js'
|
|
@@ -147,6 +159,7 @@ import {
|
|
|
147
159
|
stopCodexModelCatalogRefresh,
|
|
148
160
|
} from './lib/codex-model-catalog.js'
|
|
149
161
|
import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
|
|
162
|
+
import { noteCorsRejection } from './lib/cors-reject-log.js'
|
|
150
163
|
import { startWhisperPreviewServer, stopWhisperPreviewServer } from './lib/whisper-preview.js'
|
|
151
164
|
import { startLocalTtsServer, stopLocalTtsServer } from './lib/tts-local.js'
|
|
152
165
|
import { initSileroVAD } from './lib/vad-silero.js'
|
|
@@ -157,7 +170,7 @@ import { killNemotronChildren } from './lib/nemotron-cli.js'
|
|
|
157
170
|
import { getSessionRaw, logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
|
|
158
171
|
import { getMediaStore } from './lib/media-store.js'
|
|
159
172
|
import { getTurnFileStore } from './lib/turn-file-store.js'
|
|
160
|
-
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
173
|
+
import { listenerPlan, listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
161
174
|
import { serverMetrics } from './lib/server-metrics.js'
|
|
162
175
|
import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
163
176
|
import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
|
|
@@ -206,7 +219,7 @@ import { BOOT_RESUME_LEDGER_FILE, createBootResumeGate, installBootResumeGate }
|
|
|
206
219
|
import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
|
|
207
220
|
import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
|
|
208
221
|
import { createWorkBoardReader } from './lib/work-board-reader.js'
|
|
209
|
-
import { listBoard } from './lib/task-store.js'
|
|
222
|
+
import { listBoard, listBoardWithDates } from './lib/task-store.js'
|
|
210
223
|
import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
|
|
211
224
|
import { createMorningBriefRouter } from './routes/morning-brief.js'
|
|
212
225
|
import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
|
|
@@ -228,6 +241,7 @@ import { isPublicApiRequest, requireApiToken } from './lib/api-auth.js'
|
|
|
228
241
|
import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
|
|
229
242
|
import { createPairingRouter } from './routes/pairing.js'
|
|
230
243
|
import { createWebControlRouter, isAllowedCorsOrigin, webControlCsp, webControlEnabled } from './routes/web-control.js'
|
|
244
|
+
import { webControlOffnetResponse } from './lib/web-control-offnet-page.js'
|
|
231
245
|
import { createWebControlStatic } from './lib/web-control-static.js'
|
|
232
246
|
import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
|
|
233
247
|
import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
|
|
@@ -311,9 +325,10 @@ app.use(cors({
|
|
|
311
325
|
if (!origin || origin === 'null') return cb(null, true)
|
|
312
326
|
// Async only for an unrecognised *.ts.net origin (see isAllowedCorsOrigin); every other
|
|
313
327
|
// origin is decided from cached state, as before.
|
|
328
|
+
// S1 (2026-11-09): the rejected origin is logged (origin only, once per origin per hour).
|
|
314
329
|
isAllowedCorsOrigin(origin, getTailnetSelfName()).then(
|
|
315
|
-
ok => ok
|
|
316
|
-
() => cb(new Error('CORS blocked')),
|
|
330
|
+
ok => { if (ok) return cb(null, true); noteCorsRejection(origin, 'not_allowed'); cb(new Error('CORS blocked')) },
|
|
331
|
+
() => { noteCorsRejection(origin, 'check_failed'); cb(new Error('CORS blocked')) },
|
|
317
332
|
)
|
|
318
333
|
},
|
|
319
334
|
// Without this, a cross-origin reader sees only the CORS-safelisted response
|
|
@@ -361,6 +376,17 @@ const permissionBroker = new PermissionBroker({
|
|
|
361
376
|
return !!signal && !signal.ended && signal.waiting?.fingerprint === fingerprint
|
|
362
377
|
},
|
|
363
378
|
signals: brokerSignalSink(sessionSignalStore),
|
|
379
|
+
// X1: the full hold (lib/full-hold.ts). Both switches default OFF; the registry is read only while one is on.
|
|
380
|
+
fullHold: () => fullHoldSwitches(process.env),
|
|
381
|
+
engineFor: (sessionId, ppid) => engineForHookProcess(sessionId, ppid),
|
|
382
|
+
// X1 round 3: the asking engine's own status, read each tick for full-hold items only.
|
|
383
|
+
engineStatus: (sessionId, pid) => registryStatusForPid(pid, sessionId),
|
|
384
|
+
// X1: per kind once the PermissionRequest hook is split; the one number while it is not. Read only by the full hold.
|
|
385
|
+
installedHookTimeoutForKindS: kind => {
|
|
386
|
+
const status = cachedHookStatus()
|
|
387
|
+
const perKind = status.permissionHookTimeouts ? status.permissionHookTimeouts[kind] : undefined
|
|
388
|
+
return perKind === undefined ? status.permissionHookTimeoutS : perKind
|
|
389
|
+
},
|
|
364
390
|
})
|
|
365
391
|
registerPermissionBroker(permissionBroker)
|
|
366
392
|
wirePermissionBrokerToSignals(permissionBroker, sessionSignalStore)
|
|
@@ -384,6 +410,40 @@ app.use(createPermissionBrokerHookRouter({ hookToken: readHookToken, broker: per
|
|
|
384
410
|
// it (tailnetSelf and HTTPS_PORT are read at request time, after they are set below). It is
|
|
385
411
|
// recorded, never vouched: the cookie is an HMAC of API_TOKEN, so any token holder can mint one.
|
|
386
412
|
const clientInstanceOwners = new Map<string, ClientInstanceOwner>()
|
|
413
|
+
// 6.99.31: the enrolled phone key (lib/web-key-authority.ts), behind COS_WEB_KEY_AUTHORITY (default
|
|
414
|
+
// off, read at call time). Off, no envelope is read and no /api/web-key route exists. The stores only
|
|
415
|
+
// read their files here (none exist until the feature is used); nothing is written while it is off.
|
|
416
|
+
const webKeySelfDnsName = (): string | null => {
|
|
417
|
+
const name = tailnetSelf.currentSelfDnsName()
|
|
418
|
+
return typeof name === 'string' && name.trim() ? name.trim().toLowerCase().replace(/\.$/, '') : null
|
|
419
|
+
}
|
|
420
|
+
const webKeyRegistry = new WebKeyRegistry(dataPath(WEB_KEYS_FILE), dataPath(WEB_KEY_AUDIT_FILE))
|
|
421
|
+
const webKeyAuthority = new WebKeyAuthority({
|
|
422
|
+
registry: webKeyRegistry,
|
|
423
|
+
nonces: new WebKeyNonces(dataPath(WEB_KEY_NONCES_FILE)),
|
|
424
|
+
packets: new WebKeyPackets(),
|
|
425
|
+
selfDnsName: webKeySelfDnsName,
|
|
426
|
+
// The ONE origin a phone signs from (review B2/W2): the signer listener on 3144 under this Mac's
|
|
427
|
+
// MagicDNS name. The passkey's clientDataJSON.origin and the cookie gate's extra port use it too.
|
|
428
|
+
expectedOrigin: () => webKeySignerOrigin(webKeySelfDnsName()),
|
|
429
|
+
// Review W1: no vouch unless served-asset integrity is running AND its last check passed.
|
|
430
|
+
// (webControlIntegrity is declared further down; this runs at request time, after it is set.)
|
|
431
|
+
integrity: () => {
|
|
432
|
+
// Phase 3: the signer page on 3144 holds the key, so its own manifest must pass too.
|
|
433
|
+
return combinedIntegrityReport(webControlIntegrity, webKeySignerIntegrity)
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
// Phase 3 (review W2): the 3143 to 3144 handoff. In memory, two minutes, claimed once. Unused while the flag is off.
|
|
437
|
+
const webKeySignHandoffs = new WebKeySignHandoffs()
|
|
438
|
+
// Phase 3: the signer page's own integrity check (server/web-signer/.manifest.json). Built only with the
|
|
439
|
+
// flag on at boot, as the 3144 listener is (listenerPlan); off, the directory is never read.
|
|
440
|
+
const webKeySignerIntegrity: WebControlIntegrity | null = webKeyAuthorityEnabled()
|
|
441
|
+
? new WebControlIntegrity(path.join(path.dirname(fileURLToPath(import.meta.url)), 'web-signer'), { log: line => console.error(line.replace('[web-control-integrity]', '[web-signer-integrity]').replace('/control is', 'the 3144 signer is')) })
|
|
442
|
+
: null
|
|
443
|
+
// Review W6: enrollment starts only from a device on COS_WEB_KEY_DEVICES (empty: none).
|
|
444
|
+
const webKeyEnrollments = new WebKeyEnrollments(webKeyRegistry, { deviceAllowed: deviceAllowlistFrom() })
|
|
445
|
+
// The ports a COS Control Web cookie is honoured on. Flag off: exactly 6.99.29's [3141, 3143].
|
|
446
|
+
const webCookiePorts = (): number[] => (webKeyAuthorityEnabled() ? [PORT, HTTPS_PORT, WEB_KEY_SIGNER_PORT] : [PORT, HTTPS_PORT])
|
|
387
447
|
const g2Provenance = new G2TurnProvenance({
|
|
388
448
|
ledger: new G2TurnLedger(dataPath(G2_TURN_LEDGER_FILE)),
|
|
389
449
|
pairedInstance: (device, now, freshMs) => pairedInstanceFrom(clientInstanceOwners, device, now, freshMs),
|
|
@@ -393,8 +453,9 @@ const g2Provenance = new G2TurnProvenance({
|
|
|
393
453
|
socket: { remoteAddress: req.socket?.remoteAddress ?? undefined },
|
|
394
454
|
}, API_TOKEN, {
|
|
395
455
|
selfDnsName: tailnetSelf.currentSelfDnsName(),
|
|
396
|
-
allowedPorts:
|
|
456
|
+
allowedPorts: webCookiePorts(),
|
|
397
457
|
}),
|
|
458
|
+
webKey: webKeyAuthority,
|
|
398
459
|
})
|
|
399
460
|
|
|
400
461
|
if (threadAttachEnabled()) {
|
|
@@ -443,7 +504,7 @@ const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
|
|
|
443
504
|
app.use('/api', requireApiToken(API_TOKEN, {
|
|
444
505
|
onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
|
|
445
506
|
selfDnsName: () => tailnetSelf.currentSelfDnsName(),
|
|
446
|
-
allowedPorts: () =>
|
|
507
|
+
allowedPorts: () => webCookiePorts(),
|
|
447
508
|
}))
|
|
448
509
|
// Past the token gate, a request on a non-public path carried the token: mark a paired phone.
|
|
449
510
|
// Only the X-Cos-Token header counts: a browser signed in to COS Control Web passed the gate
|
|
@@ -932,6 +993,25 @@ app.use('/api', diagRouter)
|
|
|
932
993
|
app.use('/api', createClientInstanceRouter({ owners: clientInstanceOwners }))
|
|
933
994
|
// G2 authority Tier 1: read-only provenance for one delivered turn (verify_g2_turn.py asks it).
|
|
934
995
|
app.use('/api', createG2TurnsRouter({ provenance: g2Provenance, apiToken: () => API_TOKEN }))
|
|
996
|
+
// 6.99.31: the enrolled phone key registry and ceremony. Not a route at all while COS_WEB_KEY_AUTHORITY is off,
|
|
997
|
+
// except (7.0.9) the anchor status and registration routes with COS_WEB_KEY_ANCHOR_BOOTSTRAP on (routes/web-key.ts).
|
|
998
|
+
// Nothing else in this file reads that switch: the 3144 listener, its integrity and the cookie ports stay on the authority.
|
|
999
|
+
app.use('/api', createWebKeyRouter({
|
|
1000
|
+
authority: webKeyAuthority,
|
|
1001
|
+
enrollments: webKeyEnrollments,
|
|
1002
|
+
isWebRequest: req => g2Provenance.originOf(req).origin.client === 'web',
|
|
1003
|
+
apiToken: () => API_TOKEN,
|
|
1004
|
+
selfDnsName: webKeySelfDnsName,
|
|
1005
|
+
expectedOrigin: () => webKeyAuthority.deps.expectedOrigin(),
|
|
1006
|
+
integrityStatus: () => ({ ...(webControlIntegrity?.status() ?? { enabled: false }), signer: webKeySignerIntegrity?.status() ?? null }),
|
|
1007
|
+
// Phase 3: the phone's key steps and the handoff claim answer only on the 3144 signer listener.
|
|
1008
|
+
isSignerRequest: req => req.socket?.localPort === WEB_KEY_SIGNER_PORT,
|
|
1009
|
+
handoffs: webKeySignHandoffs,
|
|
1010
|
+
resolveBinding: bindingId => {
|
|
1011
|
+
const b = agentSessionBindingRegistry.get(bindingId)
|
|
1012
|
+
return b ? { provider: b.provider, sessionId: b.nativeThreadId, epoch: b.epoch, targetKey: b.targetKey } : null
|
|
1013
|
+
},
|
|
1014
|
+
}))
|
|
935
1015
|
app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
936
1016
|
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
937
1017
|
}))
|
|
@@ -960,7 +1040,9 @@ app.use(['/api/work-board', '/api/tasks'], (req, res, next) => {
|
|
|
960
1040
|
app.use('/api', createWorkBoardRouter({ readBoard: workBoardReader, requests: () => handoffRequestCapabilities(workHandoffRequestStore, Date.now()) }))
|
|
961
1041
|
app.use('/api', createWorkHandoffRequestsRouter({ store: workHandoffRequestStore, readBoard: workBoardReader }))
|
|
962
1042
|
// The Today summary for COS Control Web (6.99.26 candidate): Haiku, off unless switched on, with its own caps and breaker.
|
|
963
|
-
|
|
1043
|
+
// 2026-11-09: the brief reads the DATED board (when each card last moved), through its own bounded, shared reader.
|
|
1044
|
+
const datedWorkBoardReader = createWorkBoardReader(() => listBoardWithDates())
|
|
1045
|
+
app.use('/api', createTodayBriefRouter({ brief: defaultTodayBrief(), board: () => datedWorkBoardReader.read(60_000, 3_000).catch(() => null), context: (keys, into) => readSessionContext(keys, into), calendar: () => calendarToday() }))
|
|
964
1046
|
// Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
|
|
965
1047
|
const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
|
|
966
1048
|
app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
@@ -1073,6 +1155,8 @@ app.use('/api', transcribeRouter)
|
|
|
1073
1155
|
app.use('/api', sessionIndexRouter)
|
|
1074
1156
|
// Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
|
|
1075
1157
|
app.use('/api', agentSessionsRouter)
|
|
1158
|
+
// Step 2a (Sessions chat-style layout): COS folders, COS pins and renamed titles. Additive; an older server 404s these.
|
|
1159
|
+
app.use('/api', createSessionOrganizerRouter())
|
|
1076
1160
|
// The live view of one of those sessions. Four segments ending in a literal `stream`,
|
|
1077
1161
|
// so it cannot shadow the three-segment transcript route above or the bindings
|
|
1078
1162
|
// router's `/attachability` below. READ-ONLY, and deliberately NOT behind
|
|
@@ -1551,8 +1635,13 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
1551
1635
|
if (parakeet.killed || parakeet.removed) console.log(`[stt] shutdown: killed ${parakeet.killed} Parakeet run(s), removed ${parakeet.removed} temp dir(s)`)
|
|
1552
1636
|
} catch { /* best effort */ }
|
|
1553
1637
|
stopCodexModelCatalogRefresh()
|
|
1554
|
-
|
|
1555
|
-
|
|
1638
|
+
// S1 (2026-11-09): both whisper-server children are stopped with SIGKILL and waited for
|
|
1639
|
+
// (about 2 s at most each, inside the 8 s force exit), so an update restart leaves no
|
|
1640
|
+
// Metal-teardown crash report and no orphan reparented to launchd.
|
|
1641
|
+
await Promise.all([
|
|
1642
|
+
stopWhisperServer().catch(err => console.error('[shutdown] whisper-server stop:', err)),
|
|
1643
|
+
stopWhisperPreviewServer({ final: true }),
|
|
1644
|
+
])
|
|
1556
1645
|
stopLocalTtsServer()
|
|
1557
1646
|
clearTimeout(forceExit)
|
|
1558
1647
|
process.exit(0)
|
|
@@ -1574,19 +1663,33 @@ process.on('unhandledRejection', (reason: any) => {
|
|
|
1574
1663
|
// Start HTTPS alongside HTTP — Even Hub WebView prefers HTTPS (iOS ATS).
|
|
1575
1664
|
// Optional: drop cert.pem + key.pem in server/certs/ (e.g. via mkcert) to enable.
|
|
1576
1665
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
1666
|
+
// 6.99.31 (Phase 6): served-asset integrity, OFF by default (lib/web-control-integrity.ts). On, /control
|
|
1667
|
+
// serves only bytes that matched the pack-time manifest, from memory, re-checked every 60 s, with no
|
|
1668
|
+
// disk server behind it. Read once at boot.
|
|
1669
|
+
const webControlIntegrity = webControlEnabled() && webControlIntegrityEnabled()
|
|
1670
|
+
? new WebControlIntegrity(path.join(path.dirname(fileURLToPath(import.meta.url)), 'web-control'))
|
|
1671
|
+
: null
|
|
1577
1672
|
if (webControlEnabled()) {
|
|
1578
1673
|
const webControlDir = path.join(__dirname, 'web-control')
|
|
1674
|
+
const webControlServers = webControlIntegrity
|
|
1675
|
+
? [createWebControlStatic(webControlDir, { integrity: webControlIntegrity })]
|
|
1676
|
+
: [createWebControlStatic(webControlDir), express.static(webControlDir)]
|
|
1579
1677
|
app.use('/control', (req, res, next) => {
|
|
1580
1678
|
// Loopback and Tailscale only, unless COS_WEB_CONTROL_LAN=1 (review W2).
|
|
1581
1679
|
if (!isWebControlSocketAllowed(req.socket?.remoteAddress, process.env, req.headers)) {
|
|
1582
|
-
|
|
1680
|
+
// W1 (audit 02 F1): still 403, but a browser gets a short page that says to use Tailscale (lib/web-control-offnet-page.ts).
|
|
1681
|
+
const offnet = webControlOffnetResponse(/\btext\/html\b/i.test(String(req.headers.accept ?? '')))
|
|
1682
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
1683
|
+
if (offnet.csp) res.setHeader('Content-Security-Policy', offnet.csp)
|
|
1684
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
1685
|
+
res.status(403).type(offnet.type).send(offnet.body)
|
|
1583
1686
|
return
|
|
1584
1687
|
}
|
|
1585
1688
|
res.setHeader('Content-Security-Policy', webControlCsp(req.path))
|
|
1586
1689
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
1587
1690
|
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
1588
1691
|
next()
|
|
1589
|
-
},
|
|
1692
|
+
}, ...webControlServers)
|
|
1590
1693
|
app.use('/api', createWebControlRouter({
|
|
1591
1694
|
apiToken: () => API_TOKEN,
|
|
1592
1695
|
serverVersion: () => managedServerVersion() ?? 'unknown',
|
|
@@ -1632,19 +1735,22 @@ function withRequestTimeout<T extends { requestTimeout: number }>(server: T): T
|
|
|
1632
1735
|
|
|
1633
1736
|
const certDir = path.join(__dirname, 'certs')
|
|
1634
1737
|
const listeners: RequiredListener[] = []
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1738
|
+
const httpsAvailable = existsSync(path.join(certDir, 'cert.pem'))
|
|
1739
|
+
if (!httpsAvailable) console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
|
|
1740
|
+
// 6.99.31: with COS_WEB_KEY_AUTHORITY on, the phone key's signer origin (https on 3144, same cert)
|
|
1741
|
+
// joins the REQUIRED set like 3143 (lib/listener-startup.ts listenerPlan). Flag off: 6.99.29's set.
|
|
1742
|
+
for (const entry of listenerPlan({ httpsAvailable, webKeyAuthority: webKeyAuthorityEnabled(), httpPort: PORT, httpsPort: HTTPS_PORT, signerPort: WEB_KEY_SIGNER_PORT })) {
|
|
1743
|
+
// Phase 3: 3144 serves the signer page (verified bytes only) and an allowlist of /api routes,
|
|
1744
|
+
// never the big app (lib/web-key-signer-app.ts). It exists only with the flag on.
|
|
1745
|
+
const handler = entry.label === 'HTTPS signer'
|
|
1746
|
+
? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()) })
|
|
1747
|
+
: app
|
|
1748
|
+
const server = entry.tls
|
|
1749
|
+
? withRequestTimeout(createHttpsServer({ cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) }, handler))
|
|
1750
|
+
: withRequestTimeout(createHttpServer(app))
|
|
1751
|
+
listeners.push({ server, port: entry.port, host: BIND_HOST, label: entry.label })
|
|
1643
1752
|
}
|
|
1644
1753
|
|
|
1645
|
-
const httpServer = withRequestTimeout(createHttpServer(app))
|
|
1646
|
-
listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
|
|
1647
|
-
|
|
1648
1754
|
listenRequiredServers(listeners).then(() => {
|
|
1649
1755
|
const serverInstanceId = initializeServerInstanceId()
|
|
1650
1756
|
const startupAdmissionsOpen = maintenanceAdmissionsOpen()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Older sessions in a short form (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md, B2).
|
|
3
|
+
*
|
|
4
|
+
* The main list (listAgentSessions) keeps its 7-day window and its per-agent cap of 20, so its 5-second poll stays the
|
|
5
|
+
* size it is. On Miles's Mac that cap shows only the newest ~4 hours of Claude sessions (333 transcripts in 7 days,
|
|
6
|
+
* measured 2026-11-10). This walk answers the rest of the last `days` days as SHORT rows: id, agent, title, project,
|
|
7
|
+
* dates. No state, activity or model: those come from the detail route when a session is opened.
|
|
8
|
+
*
|
|
9
|
+
* - Paged newest first across all agents by `before` (exclusive) and `limit`; `nextBefore` is null on the last page.
|
|
10
|
+
* - Leaves out the ids in `exclude` (live rows, passed by the route) and each agent's own pins. A row may still also be
|
|
11
|
+
* in the main list; the client keeps the main row.
|
|
12
|
+
* - Includes the ids in `keep` (COS pins, folder members, COS-started sessions) AT ANY AGE (Q1 FIX 1, lead ruling
|
|
13
|
+
* 2026-11-10): the main list holds at most KEPT_LIST_MAX of them, so a kept row past that ceiling comes back here as
|
|
14
|
+
* a short row and is never silently dropped. The ones the main list does have are repeats the client drops.
|
|
15
|
+
* - Leaves out keep-warm sessions and Codex subagent rollouts, as the main list does.
|
|
16
|
+
* - Titles are read with the main list's own rules and memoized on file mtime and size. These files are days old and
|
|
17
|
+
* rarely change, so a repeat call re-reads almost nothing.
|
|
18
|
+
* - `olderThanWindow` counts sessions past `days` that are not kept or pinned: the "N older through search" line.
|
|
19
|
+
*/
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import {
|
|
22
|
+
AGENT_SESSION_MAX_FILE_BYTES,
|
|
23
|
+
CLAUDE_UUID_JSONL,
|
|
24
|
+
codexSessionRootsOf,
|
|
25
|
+
createdFromCodexFilename,
|
|
26
|
+
dirents,
|
|
27
|
+
fileStat,
|
|
28
|
+
firstClaudeUserTitle,
|
|
29
|
+
firstCursorUserTitle,
|
|
30
|
+
idFromCodexFilename,
|
|
31
|
+
isKeepWarmSessionTitle,
|
|
32
|
+
isoFromMtime,
|
|
33
|
+
listCodexJsonlFiles,
|
|
34
|
+
loadClaudeDesktopAliases,
|
|
35
|
+
loadClaudeStarredIds,
|
|
36
|
+
loadCodexPinnedIdsAll,
|
|
37
|
+
loadCodexThreadNamesAll,
|
|
38
|
+
loadCursorComposerNames,
|
|
39
|
+
loadCursorPinnedIds,
|
|
40
|
+
peekClaudeDiscussion,
|
|
41
|
+
peekCodexMeta,
|
|
42
|
+
peekCursorDiscussion,
|
|
43
|
+
workspaceLabel,
|
|
44
|
+
type AgentProvider,
|
|
45
|
+
type AgentSessionRoots,
|
|
46
|
+
type ClaudeAliasMap,
|
|
47
|
+
} from './agent-session-store.js'
|
|
48
|
+
|
|
49
|
+
export const OLDER_DEFAULT_DAYS = 30
|
|
50
|
+
export const OLDER_MIN_DAYS = 2
|
|
51
|
+
export const OLDER_MAX_DAYS = 30
|
|
52
|
+
export const OLDER_DEFAULT_LIMIT = 100
|
|
53
|
+
export const OLDER_MAX_LIMIT = 200
|
|
54
|
+
|
|
55
|
+
export interface OlderSessionRow {
|
|
56
|
+
session_id: string
|
|
57
|
+
provider: AgentProvider
|
|
58
|
+
display_label: string
|
|
59
|
+
project: string
|
|
60
|
+
modified: string
|
|
61
|
+
created: string
|
|
62
|
+
form: 'short'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Candidate { provider: AgentProvider; id: string; file: string; mtimeMs: number; birthtimeMs: number; size: number; folder: string }
|
|
66
|
+
interface Resolved { title: string; project: string; created: string; id: string; skip: boolean }
|
|
67
|
+
|
|
68
|
+
/** file -> what was read for this exact mtime and size. Bounded; cleared oldest-first. */
|
|
69
|
+
const titleMemo = new Map<string, { mtimeMs: number; size: number; resolved: Resolved }>()
|
|
70
|
+
const TITLE_MEMO_MAX = 5_000
|
|
71
|
+
export const olderTitleStats = { reads: 0, memoHits: 0 }
|
|
72
|
+
export function __resetOlderTitleMemoForTests(): void { titleMemo.clear(); olderTitleStats.reads = 0; olderTitleStats.memoHits = 0 }
|
|
73
|
+
|
|
74
|
+
async function memoized(candidate: Candidate, read: () => Promise<Resolved>): Promise<Resolved> {
|
|
75
|
+
const hit = titleMemo.get(candidate.file)
|
|
76
|
+
if (hit && hit.mtimeMs === candidate.mtimeMs && hit.size === candidate.size) { olderTitleStats.memoHits++; return hit.resolved }
|
|
77
|
+
olderTitleStats.reads++
|
|
78
|
+
const resolved = await read()
|
|
79
|
+
titleMemo.delete(candidate.file)
|
|
80
|
+
titleMemo.set(candidate.file, { mtimeMs: candidate.mtimeMs, size: candidate.size, resolved })
|
|
81
|
+
while (titleMemo.size > TITLE_MEMO_MAX) titleMemo.delete(titleMemo.keys().next().value!)
|
|
82
|
+
return resolved
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function listOlderSessions(roots: AgentSessionRoots, now: Date, options: {
|
|
86
|
+
days?: number
|
|
87
|
+
before?: number
|
|
88
|
+
limit?: number
|
|
89
|
+
exclude?: Partial<Record<AgentProvider, ReadonlySet<string>>>
|
|
90
|
+
/** Listed at any age, outside `days` (see the header). */
|
|
91
|
+
keep?: Partial<Record<AgentProvider, ReadonlySet<string>>>
|
|
92
|
+
aliases?: ClaudeAliasMap
|
|
93
|
+
} = {}): Promise<{ rows: OlderSessionRow[]; nextBefore: string | null; olderThanWindow: number }> {
|
|
94
|
+
const days = Math.min(OLDER_MAX_DAYS, Math.max(OLDER_MIN_DAYS, Math.floor(options.days ?? OLDER_DEFAULT_DAYS)))
|
|
95
|
+
const limit = Math.min(OLDER_MAX_LIMIT, Math.max(1, Math.floor(options.limit ?? OLDER_DEFAULT_LIMIT)))
|
|
96
|
+
const cutoff = now.getTime() - days * 24 * 3600 * 1000
|
|
97
|
+
const before = Number.isFinite(options.before) ? options.before! : Number.POSITIVE_INFINITY
|
|
98
|
+
const excluded = (provider: AgentProvider, id: string) => options.exclude?.[provider]?.has(id.toLowerCase()) ?? false
|
|
99
|
+
const kept = (provider: AgentProvider, id: string) => options.keep?.[provider]?.has(id.toLowerCase()) ?? false
|
|
100
|
+
|
|
101
|
+
const aliases = options.aliases ?? await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
102
|
+
const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
|
|
103
|
+
const claudeStars = new Set([...await loadClaudeStarredIds(roots.claudeDesktopConfig)].map(id => canonical(id).toLowerCase()))
|
|
104
|
+
const codexPinned = await loadCodexPinnedIdsAll(roots)
|
|
105
|
+
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
106
|
+
|
|
107
|
+
let olderThanWindow = 0
|
|
108
|
+
const candidates: Candidate[] = []
|
|
109
|
+
const consider = (c: Candidate, pinned: boolean) => {
|
|
110
|
+
if (pinned || excluded(c.provider, c.id)) return
|
|
111
|
+
if (c.mtimeMs < cutoff && !kept(c.provider, c.id)) { olderThanWindow++; return }
|
|
112
|
+
candidates.push(c)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const folder of await dirents(roots.claudeProjects)) {
|
|
116
|
+
const dir = join(roots.claudeProjects, folder)
|
|
117
|
+
for (const name of await dirents(dir)) {
|
|
118
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
119
|
+
const id = name.slice(0, -6).toLowerCase()
|
|
120
|
+
const st = await fileStat(join(dir, name))
|
|
121
|
+
if (!st?.isFile) continue
|
|
122
|
+
consider({ provider: 'claude', id, file: join(dir, name), mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }, claudeStars.has(id))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const root of codexSessionRootsOf(roots)) {
|
|
126
|
+
for (const file of await listCodexJsonlFiles(root)) {
|
|
127
|
+
const id = idFromCodexFilename(file.split('/').pop() || '')
|
|
128
|
+
if (!id) continue
|
|
129
|
+
const st = await fileStat(file)
|
|
130
|
+
if (!st?.isFile) continue
|
|
131
|
+
consider({ provider: 'codex', id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder: '' }, codexPinned.has(id))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const cursorById = new Map<string, Candidate>()
|
|
135
|
+
for (const folder of await dirents(roots.cursorProjects)) {
|
|
136
|
+
if (folder.includes('var-folders') || folder.includes('private-var') || folder === 'empty-window') continue
|
|
137
|
+
const transcripts = join(roots.cursorProjects, folder, 'agent-transcripts')
|
|
138
|
+
for (const sessionDir of await dirents(transcripts)) {
|
|
139
|
+
if (sessionDir === 'subagents') continue
|
|
140
|
+
const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
|
|
141
|
+
const st = await fileStat(file)
|
|
142
|
+
if (!st?.isFile || st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
|
|
143
|
+
const id = sessionDir.toLowerCase()
|
|
144
|
+
const next: Candidate = { provider: 'cursor', id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }
|
|
145
|
+
const prev = cursorById.get(id)
|
|
146
|
+
if (!prev || next.mtimeMs > prev.mtimeMs) cursorById.set(id, next)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const c of cursorById.values()) consider(c, cursorPinned.has(c.id))
|
|
150
|
+
|
|
151
|
+
const page = candidates.filter(c => c.mtimeMs < before).sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
152
|
+
const codexNames = page.some(c => c.provider === 'codex') ? await loadCodexThreadNamesAll(roots) : new Map<string, string>()
|
|
153
|
+
const cursorNames = page.some(c => c.provider === 'cursor') && roots.cursorComposerDb
|
|
154
|
+
? await loadCursorComposerNames(roots.cursorComposerDb) : new Map<string, string>()
|
|
155
|
+
const desktopTitles = new Map<string, string>()
|
|
156
|
+
for (const [id, head] of aliases) if (head?.title) desktopTitles.set(canonical(id).toLowerCase(), head.title)
|
|
157
|
+
|
|
158
|
+
const rows: OlderSessionRow[] = []
|
|
159
|
+
let consumed = 0
|
|
160
|
+
for (const c of page) {
|
|
161
|
+
if (rows.length >= limit) break
|
|
162
|
+
consumed++
|
|
163
|
+
const r = await memoized(c, () => resolve(c, codexNames, cursorNames, desktopTitles))
|
|
164
|
+
if (r.skip) continue
|
|
165
|
+
rows.push({ session_id: r.id, provider: c.provider, display_label: r.title, project: r.project, modified: isoFromMtime(c.mtimeMs), created: r.created, form: 'short' })
|
|
166
|
+
}
|
|
167
|
+
const more = consumed < page.length
|
|
168
|
+
return {
|
|
169
|
+
rows,
|
|
170
|
+
nextBefore: more && consumed > 0 ? isoFromMtime(page[consumed - 1].mtimeMs) : null,
|
|
171
|
+
olderThanWindow,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolve(c: Candidate, codexNames: Map<string, string>, cursorNames: Map<string, string>, desktopTitles: Map<string, string>): Promise<Resolved> {
|
|
176
|
+
const created = isoFromMtime(c.birthtimeMs)
|
|
177
|
+
if (c.provider === 'claude') {
|
|
178
|
+
const peek = await peekClaudeDiscussion(c.file)
|
|
179
|
+
const title = peek.customTitle ?? await firstClaudeUserTitle(c.file) ?? desktopTitles.get(c.id) ?? 'Claude session'
|
|
180
|
+
return { id: c.id, title, project: workspaceLabel(c.folder), created, skip: isKeepWarmSessionTitle(title) }
|
|
181
|
+
}
|
|
182
|
+
if (c.provider === 'codex') {
|
|
183
|
+
const meta = await peekCodexMeta(c.file)
|
|
184
|
+
if (!meta || meta.subagent) return { id: c.id, title: '', project: '', created, skip: true }
|
|
185
|
+
// The main list's rule: the filename wins when the meta id disagrees (a cloned rollout copies its parent's meta).
|
|
186
|
+
const metaId = (meta.id || '').trim().toLowerCase()
|
|
187
|
+
const id = metaId && metaId !== c.id ? c.id : (metaId || c.id)
|
|
188
|
+
const title = codexNames.get(id) || meta.title || 'Codex session'
|
|
189
|
+
const name = c.file.split('/').pop() || ''
|
|
190
|
+
return { id, title, project: workspaceLabel(meta.cwd), created: meta.created || createdFromCodexFilename(name) || created, skip: isKeepWarmSessionTitle(title) }
|
|
191
|
+
}
|
|
192
|
+
const peek = await peekCursorDiscussion(c.file)
|
|
193
|
+
const title = cursorNames.get(c.id) || await firstCursorUserTitle(c.file) || peek.lastUser || 'Cursor session'
|
|
194
|
+
return { id: c.id, title, project: workspaceLabel(c.folder), created, skip: isKeepWarmSessionTitle(title) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
/** One session's short row from its transcript file (renamed-title search hits). Null when it cannot be read or is a
|
|
199
|
+
* keep-warm or subagent session. Uses the same title rules and memo as the older walk. */
|
|
200
|
+
export async function shortRowFor(provider: AgentProvider, file: string, aliases?: ClaudeAliasMap, roots?: AgentSessionRoots): Promise<OlderSessionRow | null> {
|
|
201
|
+
const st = await fileStat(file)
|
|
202
|
+
if (!st?.isFile) return null
|
|
203
|
+
const parts = file.split('/')
|
|
204
|
+
const name = parts.pop() || ''
|
|
205
|
+
const id = provider === 'claude' ? name.replace(/\.jsonl$/, '').toLowerCase()
|
|
206
|
+
: provider === 'codex' ? (idFromCodexFilename(name) ?? '')
|
|
207
|
+
: (parts[parts.length - 1] || '').toLowerCase()
|
|
208
|
+
if (!id) return null
|
|
209
|
+
const folder = provider === 'claude' ? parts[parts.length - 1] || '' : provider === 'cursor' ? parts[parts.length - 3] || '' : ''
|
|
210
|
+
const c: Candidate = { provider, id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }
|
|
211
|
+
const codexNames = provider === 'codex' && roots ? await loadCodexThreadNamesAll(roots) : new Map<string, string>()
|
|
212
|
+
const cursorNames = provider === 'cursor' && roots?.cursorComposerDb ? await loadCursorComposerNames(roots.cursorComposerDb) : new Map<string, string>()
|
|
213
|
+
const desktopTitles = new Map<string, string>()
|
|
214
|
+
for (const head of (aliases ?? new Map()).values()) if (head?.title && head.cliSessionId?.toLowerCase() === id) desktopTitles.set(id, head.title)
|
|
215
|
+
const r = await memoized(c, () => resolve(c, codexNames, cursorNames, desktopTitles))
|
|
216
|
+
if (r.skip) return null
|
|
217
|
+
return { session_id: r.id, provider, display_label: r.title, project: r.project, modified: isoFromMtime(c.mtimeMs), created: r.created, form: 'short' }
|
|
218
|
+
}
|