@gotcos/glasses-server 7.0.18 → 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/CHANGELOG.md +135 -0
- package/package.json +1 -1
- package/server/index.ts +3 -0
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-store.ts +99 -17
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-stream-events.ts +14 -2
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/thread-turn-queue.ts +34 -5
- package/server/web-control/.manifest.json +7 -7
- package/server/web-control/app.css +9 -0
- package/server/web-control/app.js +732 -56
- package/server/web-control/index.html +40 -16
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/today-actions.js +46 -0
- package/server/web-control/panes/work.js +7 -3
- package/server/web-control/theme.js +33 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session organizer (Sessions chat-style layout, step 2a; contract
|
|
3
|
+
* operations/personal/wk46_2026/sessions_chat_layout/S1_CONTRACT_step2a.md).
|
|
4
|
+
*
|
|
5
|
+
* COS's own folders, COS pins and renamed titles for agent sessions, shared by Control Web and the Mac app. It only
|
|
6
|
+
* ADDS: nothing an agent wrote is changed (a COS pin is not a Claude star), and a list without this store is today's.
|
|
7
|
+
*
|
|
8
|
+
* - One file, data/session-organizer.json, 0600, written durably (temp file, fsync, rename) on every change.
|
|
9
|
+
* - Keyed `<provider>:<canonical id>`, lowercase. The caller canonicalizes (Claude Desktop ids map to their CLI id)
|
|
10
|
+
* BEFORE it reaches this module, so this module never guesses identity.
|
|
11
|
+
* - One change per call, applied in memory and written at once; `rev` rises by one per change. Nothing takes a whole
|
|
12
|
+
* store, so two devices editing different things never overwrite each other.
|
|
13
|
+
* - Folder names and titles never reach a log line: logs carry counts and reason codes only.
|
|
14
|
+
* - A file that exists but cannot be READ makes the store unavailable (no writes), so a transient read error can
|
|
15
|
+
* never be followed by a write that replaces real data with an empty store. A file that reads but does not parse,
|
|
16
|
+
* or has the wrong shape, is moved aside as `.corrupt-<time>` and the store starts empty.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
|
|
19
|
+
import { randomBytes } from 'node:crypto'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
22
|
+
import { dataPath } from './data-dir.js'
|
|
23
|
+
|
|
24
|
+
export const ORGANIZER_FILE = 'session-organizer.json'
|
|
25
|
+
export const ORGANIZER_LIMITS = { folders: 200, entries: 5_000, folderName: 60, title: 120 } as const
|
|
26
|
+
export type OrganizerProvider = 'claude' | 'codex' | 'cursor'
|
|
27
|
+
const PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
28
|
+
const FOLDER_ID_RE = /^f_[0-9a-f]{16}$/
|
|
29
|
+
|
|
30
|
+
export interface OrganizerFolder { name: string; created_at: string }
|
|
31
|
+
/** `desktop_id`: for a Claude session, the Claude Desktop id it was written under when one is known (Q1 FIX 2). A row
|
|
32
|
+
* matches an entry by its key OR by this id, so an entry survives the Desktop alias appearing or changing. */
|
|
33
|
+
export interface OrganizerEntry { folder_id?: string; cos_pinned?: true; title?: string; desktop_id?: string; updated_at: string }
|
|
34
|
+
interface OrganizerFile {
|
|
35
|
+
version: 1
|
|
36
|
+
rev: number
|
|
37
|
+
folders: Record<string, OrganizerFolder>
|
|
38
|
+
sessions: Record<string, OrganizerEntry>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class OrganizerError extends Error {
|
|
42
|
+
constructor(readonly status: number, readonly reason: string) { super(reason) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a list row or search hit carries from the store (each omitted when absent). */
|
|
46
|
+
export interface OrganizerRowFields { folder_id?: string; cos_pinned?: true; title_override?: string }
|
|
47
|
+
|
|
48
|
+
/** Trim, drop control characters and line breaks, collapse inner whitespace. Empty means refused. */
|
|
49
|
+
export function cleanOrganizerText(value: unknown, max: number): string | null {
|
|
50
|
+
if (typeof value !== 'string') return null
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
53
|
+
if (!cleaned || [...cleaned].length > max) return null
|
|
54
|
+
return cleaned
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function organizerKey(provider: string, sessionId: string): string {
|
|
58
|
+
return `${provider}:${sessionId.trim().toLowerCase()}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emptyFile(): OrganizerFile { return { version: 1, rev: 0, folders: {}, sessions: {} } }
|
|
62
|
+
|
|
63
|
+
function validShape(value: unknown): value is OrganizerFile {
|
|
64
|
+
if (!value || typeof value !== 'object') return false
|
|
65
|
+
const v = value as Partial<OrganizerFile>
|
|
66
|
+
if (v.version !== 1 || typeof v.rev !== 'number' || !Number.isInteger(v.rev) || v.rev < 0) return false
|
|
67
|
+
if (!v.folders || typeof v.folders !== 'object' || Array.isArray(v.folders)) return false
|
|
68
|
+
if (!v.sessions || typeof v.sessions !== 'object' || Array.isArray(v.sessions)) return false
|
|
69
|
+
for (const [id, folder] of Object.entries(v.folders)) {
|
|
70
|
+
if (!FOLDER_ID_RE.test(id) || !folder || typeof folder.name !== 'string' || typeof folder.created_at !== 'string') return false
|
|
71
|
+
}
|
|
72
|
+
for (const [key, entry] of Object.entries(v.sessions)) {
|
|
73
|
+
const provider = key.split(':', 1)[0]
|
|
74
|
+
if (!PROVIDERS.has(provider) || key.length <= provider.length + 1 || !entry || typeof entry.updated_at !== 'string') return false
|
|
75
|
+
if (entry.folder_id !== undefined && (typeof entry.folder_id !== 'string' || !v.folders[entry.folder_id])) return false
|
|
76
|
+
if (entry.cos_pinned !== undefined && entry.cos_pinned !== true) return false
|
|
77
|
+
if (entry.title !== undefined && typeof entry.title !== 'string') return false
|
|
78
|
+
if (entry.desktop_id !== undefined && typeof entry.desktop_id !== 'string') return false
|
|
79
|
+
}
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type OrganizerLog = (line: string) => void
|
|
84
|
+
|
|
85
|
+
export class SessionOrganizer {
|
|
86
|
+
private state: OrganizerFile = emptyFile()
|
|
87
|
+
private available = true
|
|
88
|
+
|
|
89
|
+
constructor(private readonly path: string = dataPath(ORGANIZER_FILE), private readonly log: OrganizerLog = line => console.warn(line),
|
|
90
|
+
private readonly now: () => Date = () => new Date()) {
|
|
91
|
+
this.load()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private load(): void {
|
|
95
|
+
if (!existsSync(this.path)) return
|
|
96
|
+
let raw: string
|
|
97
|
+
try {
|
|
98
|
+
raw = readFileSync(this.path, 'utf8')
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Exists but unreadable (permission, I/O): never write over it.
|
|
101
|
+
this.available = false
|
|
102
|
+
this.log(`[session-organizer] unavailable: the store exists but could not be read (${(error as NodeJS.ErrnoException)?.code ?? 'error'})`)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
let parsed: unknown
|
|
106
|
+
try { parsed = JSON.parse(raw) } catch { parsed = undefined }
|
|
107
|
+
if (validShape(parsed)) { this.state = parsed; return }
|
|
108
|
+
const aside = `${this.path}.corrupt-${Date.now()}`
|
|
109
|
+
try {
|
|
110
|
+
renameSync(this.path, aside)
|
|
111
|
+
this.log('[session-organizer] the store did not parse; moved aside and started empty (reason=corrupt_store)')
|
|
112
|
+
} catch {
|
|
113
|
+
this.available = false
|
|
114
|
+
this.log('[session-organizer] unavailable: the store did not parse and could not be moved aside (reason=corrupt_store)')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
isAvailable(): boolean { return this.available }
|
|
119
|
+
rev(): number { return this.state.rev }
|
|
120
|
+
isEmpty(): boolean { return Object.keys(this.state.folders).length === 0 && Object.keys(this.state.sessions).length === 0 }
|
|
121
|
+
|
|
122
|
+
private requireAvailable(): void {
|
|
123
|
+
if (!this.available) throw new OrganizerError(503, 'organizer_unavailable')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private commit(next: OrganizerFile): void {
|
|
127
|
+
next.rev = this.state.rev + 1
|
|
128
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
|
|
129
|
+
durableAtomicWriteFileSync(this.path, JSON.stringify(next), { mode: 0o600 })
|
|
130
|
+
this.state = next
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private clone(): OrganizerFile { return structuredClone(this.state) }
|
|
134
|
+
|
|
135
|
+
private counts(): Map<string, number> {
|
|
136
|
+
const counts = new Map<string, number>()
|
|
137
|
+
for (const entry of Object.values(this.state.sessions)) {
|
|
138
|
+
if (entry.folder_id) counts.set(entry.folder_id, (counts.get(entry.folder_id) ?? 0) + 1)
|
|
139
|
+
}
|
|
140
|
+
return counts
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
listFolders(): Array<{ id: string; name: string; created_at: string; count: number }> {
|
|
144
|
+
const counts = this.counts()
|
|
145
|
+
return Object.entries(this.state.folders)
|
|
146
|
+
.map(([id, f]) => ({ id, name: f.name, created_at: f.created_at, count: counts.get(id) ?? 0 }))
|
|
147
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.id.localeCompare(b.id))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private nameTaken(name: string, except?: string): boolean {
|
|
151
|
+
const lower = name.toLocaleLowerCase()
|
|
152
|
+
return Object.entries(this.state.folders).some(([id, f]) => id !== except && f.name.toLocaleLowerCase() === lower)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
createFolder(rawName: unknown): { id: string; name: string; created_at: string; count: number } {
|
|
156
|
+
this.requireAvailable()
|
|
157
|
+
const name = cleanOrganizerText(rawName, ORGANIZER_LIMITS.folderName)
|
|
158
|
+
if (!name) throw new OrganizerError(400, 'invalid_name')
|
|
159
|
+
if (Object.keys(this.state.folders).length >= ORGANIZER_LIMITS.folders) throw new OrganizerError(409, 'too_many_folders')
|
|
160
|
+
if (this.nameTaken(name)) throw new OrganizerError(409, 'folder_name_taken')
|
|
161
|
+
let id = ''
|
|
162
|
+
do { id = `f_${randomBytes(8).toString('hex')}` } while (this.state.folders[id])
|
|
163
|
+
const next = this.clone()
|
|
164
|
+
const created_at = this.now().toISOString()
|
|
165
|
+
next.folders[id] = { name, created_at }
|
|
166
|
+
this.commit(next)
|
|
167
|
+
return { id, name, created_at, count: 0 }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
renameFolder(id: string, rawName: unknown): { id: string; name: string; created_at: string; count: number } {
|
|
171
|
+
this.requireAvailable()
|
|
172
|
+
const folder = this.state.folders[id]
|
|
173
|
+
if (!folder) throw new OrganizerError(404, 'folder_not_found')
|
|
174
|
+
const name = cleanOrganizerText(rawName, ORGANIZER_LIMITS.folderName)
|
|
175
|
+
if (!name) throw new OrganizerError(400, 'invalid_name')
|
|
176
|
+
if (this.nameTaken(name, id)) throw new OrganizerError(409, 'folder_name_taken')
|
|
177
|
+
const next = this.clone()
|
|
178
|
+
next.folders[id] = { ...folder, name }
|
|
179
|
+
this.commit(next)
|
|
180
|
+
return { id, name, created_at: folder.created_at, count: this.counts().get(id) ?? 0 }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Removes the folder. Its sessions leave it; no session is ever deleted. Returns how many left. */
|
|
184
|
+
deleteFolder(id: string): number {
|
|
185
|
+
this.requireAvailable()
|
|
186
|
+
if (!this.state.folders[id]) throw new OrganizerError(404, 'folder_not_found')
|
|
187
|
+
const next = this.clone()
|
|
188
|
+
delete next.folders[id]
|
|
189
|
+
let released = 0
|
|
190
|
+
const stamp = this.now().toISOString()
|
|
191
|
+
for (const [key, entry] of Object.entries(next.sessions)) {
|
|
192
|
+
if (entry.folder_id !== id) continue
|
|
193
|
+
released++
|
|
194
|
+
delete entry.folder_id
|
|
195
|
+
entry.updated_at = stamp
|
|
196
|
+
if (!entry.cos_pinned && entry.title === undefined) delete next.sessions[key]
|
|
197
|
+
}
|
|
198
|
+
this.commit(next)
|
|
199
|
+
return released
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One session's change. `key` must already be canonical (organizerKey on the canonical id). Keys left out of
|
|
204
|
+
* `change` are untouched; `null` clears. An entry left with nothing in it is removed.
|
|
205
|
+
*/
|
|
206
|
+
updateSession(key: string, change: { folder_id?: unknown; cos_pinned?: unknown; title?: unknown }, ids: { desktopId?: string } = {}): OrganizerRowFields {
|
|
207
|
+
this.requireAvailable()
|
|
208
|
+
const touchesFolder = Object.prototype.hasOwnProperty.call(change, 'folder_id')
|
|
209
|
+
const touchesPin = Object.prototype.hasOwnProperty.call(change, 'cos_pinned')
|
|
210
|
+
const touchesTitle = Object.prototype.hasOwnProperty.call(change, 'title')
|
|
211
|
+
if (!touchesFolder && !touchesPin && !touchesTitle) throw new OrganizerError(400, 'empty_change')
|
|
212
|
+
let folderId: string | null | undefined
|
|
213
|
+
if (touchesFolder) {
|
|
214
|
+
if (change.folder_id === null) folderId = null
|
|
215
|
+
else if (typeof change.folder_id === 'string' && this.state.folders[change.folder_id]) folderId = change.folder_id
|
|
216
|
+
else throw new OrganizerError(404, 'folder_not_found')
|
|
217
|
+
}
|
|
218
|
+
if (touchesPin && typeof change.cos_pinned !== 'boolean') throw new OrganizerError(400, 'invalid_pin')
|
|
219
|
+
let title: string | null | undefined
|
|
220
|
+
if (touchesTitle) {
|
|
221
|
+
if (change.title === null) title = null
|
|
222
|
+
else {
|
|
223
|
+
title = cleanOrganizerText(change.title, ORGANIZER_LIMITS.title)
|
|
224
|
+
if (!title) throw new OrganizerError(400, 'invalid_title')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const desktopId = ids.desktopId?.trim().toLowerCase() || undefined
|
|
228
|
+
// Q1 FIX 2: an entry written under the same Desktop id but another key (its CLI alias appeared or changed since)
|
|
229
|
+
// moves to this key, so one session never has two entries.
|
|
230
|
+
const movedFrom = !this.state.sessions[key] && desktopId
|
|
231
|
+
? Object.keys(this.state.sessions).find(k => k.startsWith('claude:') && this.state.sessions[k].desktop_id === desktopId)
|
|
232
|
+
: undefined
|
|
233
|
+
const existing = this.state.sessions[key] ?? (movedFrom ? this.state.sessions[movedFrom] : undefined)
|
|
234
|
+
if (!existing && Object.keys(this.state.sessions).length >= ORGANIZER_LIMITS.entries) throw new OrganizerError(409, 'too_many_entries')
|
|
235
|
+
const next = this.clone()
|
|
236
|
+
const entry: OrganizerEntry = { ...(existing ? structuredClone(existing) : {}), updated_at: this.now().toISOString() }
|
|
237
|
+
if (movedFrom) delete next.sessions[movedFrom]
|
|
238
|
+
if (desktopId) entry.desktop_id = desktopId
|
|
239
|
+
if (folderId === null) delete entry.folder_id; else if (folderId) entry.folder_id = folderId
|
|
240
|
+
if (touchesPin) { if (change.cos_pinned === true) entry.cos_pinned = true; else delete entry.cos_pinned }
|
|
241
|
+
if (title === null) delete entry.title; else if (title) entry.title = title
|
|
242
|
+
if (!entry.folder_id && !entry.cos_pinned && entry.title === undefined) delete next.sessions[key]
|
|
243
|
+
else next.sessions[key] = entry
|
|
244
|
+
this.commit(next)
|
|
245
|
+
return this.fieldsFor(key)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The fields for the first of these keys that has an entry, or for an entry written under one of `desktopIds`. */
|
|
249
|
+
fieldsForAny(keys: readonly string[], desktopIds: readonly string[] = []): OrganizerRowFields {
|
|
250
|
+
for (const key of keys) if (this.state.sessions[key]) return this.fieldsFor(key)
|
|
251
|
+
if (desktopIds.length) {
|
|
252
|
+
const wanted = new Set(desktopIds.map(id => id.toLowerCase()))
|
|
253
|
+
for (const [key, entry] of Object.entries(this.state.sessions)) {
|
|
254
|
+
if (entry.desktop_id && key.startsWith('claude:') && wanted.has(entry.desktop_id)) return this.fieldsFor(key)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The fields a row for this canonical key carries (empty when none). */
|
|
261
|
+
fieldsFor(key: string): OrganizerRowFields {
|
|
262
|
+
const entry = this.state.sessions[key]
|
|
263
|
+
if (!entry) return {}
|
|
264
|
+
return {
|
|
265
|
+
...(entry.folder_id ? { folder_id: entry.folder_id } : {}),
|
|
266
|
+
...(entry.cos_pinned ? { cos_pinned: true as const } : {}),
|
|
267
|
+
...(entry.title !== undefined ? { title_override: entry.title } : {}),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Canonical ids per provider that the list keeps at any age and outside its caps: COS pins and folder members. */
|
|
272
|
+
keptIds(): Record<OrganizerProvider, Set<string>> {
|
|
273
|
+
const kept: Record<OrganizerProvider, Set<string>> = { claude: new Set(), codex: new Set(), cursor: new Set() }
|
|
274
|
+
for (const [key, entry] of Object.entries(this.state.sessions)) {
|
|
275
|
+
if (!entry.cos_pinned && !entry.folder_id) continue
|
|
276
|
+
const at = key.indexOf(':')
|
|
277
|
+
kept[key.slice(0, at) as OrganizerProvider].add(key.slice(at + 1))
|
|
278
|
+
// The list maps a Desktop id to its current CLI id, so an entry follows its session when the alias changes.
|
|
279
|
+
if (entry.desktop_id) kept.claude.add(entry.desktop_id)
|
|
280
|
+
}
|
|
281
|
+
return kept
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Keys whose renamed title contains `query` (case-insensitive), for search. */
|
|
285
|
+
titleMatches(query: string): Array<{ provider: OrganizerProvider; session_id: string }> {
|
|
286
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
287
|
+
if (!needle) return []
|
|
288
|
+
const out: Array<{ provider: OrganizerProvider; session_id: string }> = []
|
|
289
|
+
for (const [key, entry] of Object.entries(this.state.sessions)) {
|
|
290
|
+
if (entry.title === undefined || !entry.title.toLocaleLowerCase().includes(needle)) continue
|
|
291
|
+
const at = key.indexOf(':')
|
|
292
|
+
out.push({ provider: key.slice(0, at) as OrganizerProvider, session_id: key.slice(at + 1) })
|
|
293
|
+
}
|
|
294
|
+
return out
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let shared: SessionOrganizer | null = null
|
|
299
|
+
/** The server's one organizer, loaded on first use. */
|
|
300
|
+
export function sessionOrganizer(): SessionOrganizer {
|
|
301
|
+
if (!shared) shared = new SessionOrganizer()
|
|
302
|
+
return shared
|
|
303
|
+
}
|
|
304
|
+
/** Tests only. */
|
|
305
|
+
export function __resetSessionOrganizerForTests(next: SessionOrganizer | null = null): void { shared = next }
|
|
@@ -1354,6 +1354,16 @@ export function draftsFromLine(provider: SessionStreamProvider, line: string): S
|
|
|
1354
1354
|
*
|
|
1355
1355
|
* Skipped: `isMeta` and `isCompactSummary` rows (injected context, not a prompt), tool
|
|
1356
1356
|
* results, and the bookkeeping rows (`system`, `last-prompt`, `attachment`, `mode`...).
|
|
1357
|
+
*
|
|
1358
|
+
* ONLY `type: 'user'` AND `type: 'assistant'` ROWS ARE THE CONVERSATION (2026-11-11). Every
|
|
1359
|
+
* other row type is skipped, whatever its `message.role`. Claude Code 2.1.300 (first seen
|
|
1360
|
+
* 2026-10-14) writes `api-request-blob` rows: no timestamp, `message.role` user or assistant,
|
|
1361
|
+
* copies of earlier blocks, written AFTER the tool results they repeat. Read as a reply, a
|
|
1362
|
+
* copied `tool_use` re-opened a tool that had already answered, the tail read `tool_pending`,
|
|
1363
|
+
* and the queue held a turn parked for an idle Desktop session until 30 minutes of quiet
|
|
1364
|
+
* (S1, lanes/S1_queue_hold_diagnosis.md: 15 of 56 ended transcripts read open). An UNKNOWN
|
|
1365
|
+
* row type therefore never opens a turn: it fails toward "ended", and the hooks, the registry
|
|
1366
|
+
* and the gate still decide whether anything is sent.
|
|
1357
1367
|
* Codex keeps its event rule (task_complete / turn_complete end, task_started opens).
|
|
1358
1368
|
*
|
|
1359
1369
|
* PURE, like everything else here. The queue store reads the tail; this decides.
|
|
@@ -1400,7 +1410,9 @@ export function turnFromTail(provider: SessionStreamProvider, lines: readonly st
|
|
|
1400
1410
|
const content = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
|
|
1401
1411
|
const blocks = content.map(asRecord).filter((b): b is Record<string, unknown> => b !== null)
|
|
1402
1412
|
|
|
1403
|
-
|
|
1413
|
+
// The conversation is `user` and `assistant` rows only, by row TYPE, never by `message.role` (see the header:
|
|
1414
|
+
// an api-request-blob carries a role, and any row type this build does not know falls through both branches).
|
|
1415
|
+
if (type === 'user') {
|
|
1404
1416
|
const results = blocks.filter(b => b.type === 'tool_result')
|
|
1405
1417
|
if (results.length > 0) {
|
|
1406
1418
|
for (const b of results) if (typeof b.tool_use_id === 'string') pendingToolUses.delete(b.tool_use_id)
|
|
@@ -1413,7 +1425,7 @@ export function turnFromTail(provider: SessionStreamProvider, lines: readonly st
|
|
|
1413
1425
|
continue
|
|
1414
1426
|
}
|
|
1415
1427
|
|
|
1416
|
-
if (type === 'assistant'
|
|
1428
|
+
if (type === 'assistant') {
|
|
1417
1429
|
for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') pendingToolUses.add(b.id)
|
|
1418
1430
|
const stop = typeof message.stop_reason === 'string' ? message.stop_reason : ''
|
|
1419
1431
|
if (TERMINAL_STOP_REASONS.has(stop)) {
|
|
@@ -122,6 +122,14 @@ export interface QueuedThreadTurn {
|
|
|
122
122
|
* the words alone, so the pending row and its preview never show a path. Absent on a turn without files.
|
|
123
123
|
*/
|
|
124
124
|
attachmentIds?: string[]
|
|
125
|
+
/**
|
|
126
|
+
* 2026-11-11 (S1, Miles's note held 08:01): WHY a `waiting` row is held, as a short code the drain decided, and
|
|
127
|
+
* since when that code has held (epoch ms). Written only when the code CHANGES, never per sweep, so a held row
|
|
128
|
+
* costs no extra writes. Additive: an older server or client ignores both. Codes only, never text
|
|
129
|
+
* (`holdReasonFor`, `HOLD_REASON_CODE`).
|
|
130
|
+
*/
|
|
131
|
+
holdReason?: string
|
|
132
|
+
holdSince?: number
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
// ------------------------------------------------------------------- 6.78.0 flags
|
|
@@ -376,6 +384,22 @@ export function drainDecision(
|
|
|
376
384
|
return seen.activity === 'idle' ? 'deliver' : 'hold'
|
|
377
385
|
}
|
|
378
386
|
|
|
387
|
+
/** A hold reason is a short code: lowercase letters, digits and underscores. Anything else reads `gate_closed`. */
|
|
388
|
+
export const HOLD_REASON_CODE = /^[a-z0-9_]{1,48}$/
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Why `drainDecision` held this row, as a code (2026-11-11). Same order as the decision, so the code names the
|
|
392
|
+
* branch that held: a cancel hold, then the gate (its own reason), then an open turn, then a turn not yet ended.
|
|
393
|
+
* Only meaningful for a row the decision held; `files_wait_for_turn_end` is set by the drain itself, when the
|
|
394
|
+
* row's files are the only thing that kept it from going into the running turn.
|
|
395
|
+
*/
|
|
396
|
+
export function holdReasonFor(seen: DrainObservation, now: number): string {
|
|
397
|
+
if (typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && now < seen.cancelHoldUntil) return 'cancel_hold'
|
|
398
|
+
if (!seen.attachable) return typeof seen.reason === 'string' && HOLD_REASON_CODE.test(seen.reason) ? seen.reason : 'gate_closed'
|
|
399
|
+
if (seen.turnOpen === true) return 'turn_open'
|
|
400
|
+
return 'turn_not_ended'
|
|
401
|
+
}
|
|
402
|
+
|
|
379
403
|
/**
|
|
380
404
|
* Admit a turn to the queue, or say why not.
|
|
381
405
|
*
|
|
@@ -91,6 +91,35 @@ import { resetPreviewLogThrottle, warnPreviewThrottled } from '../lib/preview-lo
|
|
|
91
91
|
import { sessionFilesSetting, withoutPreviews } from '../lib/session-files-setting.js'
|
|
92
92
|
import { withoutLinkMarkers } from '../lib/session-links.js'
|
|
93
93
|
import { createSessionPreviewRouter } from './session-preview.js'
|
|
94
|
+
import { organizerKey, sessionOrganizer, type OrganizerRowFields } from '../lib/session-organizer.js'
|
|
95
|
+
import { listOlderSessions, shortRowFor, OLDER_DEFAULT_DAYS, OLDER_DEFAULT_LIMIT, OLDER_MAX_DAYS, OLDER_MAX_LIMIT, OLDER_MIN_DAYS } from '../lib/agent-session-older.js'
|
|
96
|
+
import { cachedListModel, warmListModels } from '../lib/list-model-memo.js'
|
|
97
|
+
import { cosStartedSessionIds } from '../lib/cos-started-sessions.js'
|
|
98
|
+
|
|
99
|
+
/** Step 2a: the organizer, or null when it is unavailable or empty (the list is then exactly today's). */
|
|
100
|
+
function organizerForList(): ReturnType<typeof sessionOrganizer> | null {
|
|
101
|
+
try {
|
|
102
|
+
const o = sessionOrganizer()
|
|
103
|
+
return o.isAvailable() && !o.isEmpty() ? o : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Claude Desktop ids per CLI id, from the request's alias table (an entry written under a Desktop id still matches). */
|
|
109
|
+
function desktopIdsByCli(aliases: ClaudeAliasMap | undefined): Map<string, string[]> {
|
|
110
|
+
const out = new Map<string, string[]>()
|
|
111
|
+
for (const [desktopId, head] of aliases ?? new Map()) {
|
|
112
|
+
const cli = head?.cliSessionId
|
|
113
|
+
if (!cli || cli.startsWith('!')) continue
|
|
114
|
+
out.set(cli, [...(out.get(cli) ?? []), desktopId])
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
function organizerFieldsOf(o: ReturnType<typeof sessionOrganizer> | null, provider: string, sessionId: string, desktopIds?: Map<string, string[]>): OrganizerRowFields {
|
|
119
|
+
if (!o) return {}
|
|
120
|
+
const id = sessionId.toLowerCase()
|
|
121
|
+
return o.fieldsForAny([organizerKey(provider, id)], provider === 'claude' ? desktopIds?.get(id) ?? [] : [])
|
|
122
|
+
}
|
|
94
123
|
|
|
95
124
|
export const agentSessionsRouter = Router()
|
|
96
125
|
// Preview engine phase 1: GET /api/preview/agent-sessions/... and /api/preview-folders/settings (routes/session-preview.ts).
|
|
@@ -511,7 +540,17 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
511
540
|
const peers = await liveClaudePeerRecords()
|
|
512
541
|
const live = liveClaudeRows(peers)
|
|
513
542
|
const dropped = emptySessionListDropped()
|
|
514
|
-
|
|
543
|
+
// Step 2a: COS pins and folder members are listed at any age, outside the cap and `limit`.
|
|
544
|
+
const organizer = organizerForList()
|
|
545
|
+
const desktopIds = organizer ? desktopIdsByCli(aliases) : undefined
|
|
546
|
+
// 7.0.25 (lead 2026-11-11 10:29): kept rows only for a client that ASKS (`kept=1`, Control Web). The public lens
|
|
547
|
+
// (6.11.681) and Control Mac (0.5.389) do not clamp to their own limit, so a server-only update would have added
|
|
548
|
+
// up to KEPT_LIST_MAX rows to their lists unasked. Without the parameter the walk gets no keep set at all: exactly
|
|
549
|
+
// the 7.0.18 selection (same rows, same limit). The organizer fields on rows stay (additive, ignored by them).
|
|
550
|
+
const wantsKept = req.query.kept === '1'
|
|
551
|
+
// Miles, 2026-11-10: sessions a COS job started in the last 7 days stay listed (memoized journal read).
|
|
552
|
+
const started = wantsKept ? await cosStartedSessionIds() : {}
|
|
553
|
+
const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases, wantsKept && organizer ? organizer.keptIds() : {}, started)
|
|
515
554
|
// 6.45.5: each row's last real activity, read from its transcript records. Memoized on
|
|
516
555
|
// mtime and size, so a steady poll re-reads nothing; bounded so a cold list of 80 rows
|
|
517
556
|
// never holds 80 descriptors at once.
|
|
@@ -583,8 +622,12 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
583
622
|
const signal = signalFor(row.session_id)
|
|
584
623
|
const facts = rolloutById.get(row.session_id) ?? null
|
|
585
624
|
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
|
|
625
|
+
// Step 2a: the model only from the memo (never a read on the list path, 6.62.0 W11).
|
|
626
|
+
const model = cachedListModel(row.provider, row.file, Number.isFinite(Date.parse(row.modified)) ? Date.parse(row.modified) : null)
|
|
586
627
|
return {
|
|
587
628
|
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
629
|
+
...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIds),
|
|
630
|
+
...(model ? { reported_model: model } : {}),
|
|
588
631
|
...cursorActivityFields(row.provider, signal),
|
|
589
632
|
...(row.provider === 'codex'
|
|
590
633
|
? { ...(subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}), ...codexCompactionFields(workObservationFor(row.session_id), facts, now) }
|
|
@@ -602,13 +645,57 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
602
645
|
// client ignores this key. Zero means the walk found nothing to hide, not
|
|
603
646
|
// that the caps are off.
|
|
604
647
|
dropped,
|
|
648
|
+
...(organizer ? { organizer: { rev: organizer.rev() } } : {}),
|
|
605
649
|
}))
|
|
650
|
+
// Step 2a: read the models this answer lacked, in the background, for a later poll. Never awaited.
|
|
651
|
+
warmListModels(sessions)
|
|
606
652
|
} catch (error) {
|
|
607
653
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
608
654
|
res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
|
|
609
655
|
}
|
|
610
656
|
})
|
|
611
657
|
|
|
658
|
+
// Step 2a (S1_CONTRACT_step2a.md B2): the last `days` days in a short form, paged, for the compact groups. Not on the
|
|
659
|
+
// list poll: fetched when the list opens and as a group unfolds. Live rows, COS-kept rows and each agent's own pins are
|
|
660
|
+
// left out (the main list has them in full); a row may still also be in the main list, and the client keeps that one.
|
|
661
|
+
agentSessionsRouter.get('/agent-sessions/older', async (req, res) => {
|
|
662
|
+
res.set('Cache-Control', 'private, no-store')
|
|
663
|
+
try {
|
|
664
|
+
const days = boundedInteger(req.query.days, OLDER_DEFAULT_DAYS, OLDER_MIN_DAYS, OLDER_MAX_DAYS)
|
|
665
|
+
const limit = boundedInteger(req.query.limit, OLDER_DEFAULT_LIMIT, 1, OLDER_MAX_LIMIT)
|
|
666
|
+
const beforeRaw = typeof req.query.before === 'string' ? Date.parse(req.query.before) : NaN
|
|
667
|
+
if (typeof req.query.before === 'string' && !Number.isFinite(beforeRaw)) {
|
|
668
|
+
res.status(400).json({ error: 'before must be an ISO time', reason: 'invalid_before' })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
const roots = agentSessionRoots()
|
|
672
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
673
|
+
const organizer = organizerForList()
|
|
674
|
+
const kept = organizer ? organizer.keptIds() : { claude: new Set<string>(), codex: new Set<string>(), cursor: new Set<string>() }
|
|
675
|
+
const started = await cosStartedSessionIds()
|
|
676
|
+
for (const provider of ['claude', 'codex', 'cursor'] as const) for (const id of started[provider]) kept[provider].add(id)
|
|
677
|
+
// Kept Claude ids may be Desktop ids; the walk names transcripts by CLI id.
|
|
678
|
+
for (const id of [...kept.claude]) { const cli = aliases.get(id)?.cliSessionId; if (cli && !cli.startsWith('!')) kept.claude.add(cli) }
|
|
679
|
+
const liveIds = new Set(liveClaudeRows(await liveClaudePeerRecords()).map(row => row.session_id.toLowerCase()))
|
|
680
|
+
const result = await listOlderSessions(roots, new Date(), {
|
|
681
|
+
days, limit, aliases,
|
|
682
|
+
...(Number.isFinite(beforeRaw) ? { before: beforeRaw } : {}),
|
|
683
|
+
// Kept rows come back here at any age: the main list holds at most KEPT_LIST_MAX of them (Q1 FIX 1).
|
|
684
|
+
exclude: { claude: liveIds },
|
|
685
|
+
keep: kept,
|
|
686
|
+
})
|
|
687
|
+
res.json(redactLocalPathsDeep({
|
|
688
|
+
sessions: result.rows.map(row => ({ ...row, ...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIdsByCli(aliases)) })),
|
|
689
|
+
windowDays: days,
|
|
690
|
+
next_before: result.nextBefore,
|
|
691
|
+
...(Number.isFinite(beforeRaw) ? {} : { older_than_window: result.olderThanWindow }),
|
|
692
|
+
}))
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error(`[agent-sessions] older list failed: ${error instanceof Error ? error.message : error}`)
|
|
695
|
+
res.status(500).json({ error: 'Failed to read older agent sessions', reason: 'agent_sessions_read_failed' })
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
612
699
|
agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
613
700
|
res.set('Cache-Control', 'private, no-store')
|
|
614
701
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
@@ -619,6 +706,23 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
619
706
|
try {
|
|
620
707
|
const limit = boundedInteger(req.query.limit, 20, 1, 50)
|
|
621
708
|
const result = await searchAgentSessions({ query, limit })
|
|
709
|
+
// Step 2a: a session renamed in COS is found by its new title too (the agent's own title still matches as before).
|
|
710
|
+
const organizer = organizerForList()
|
|
711
|
+
if (organizer) {
|
|
712
|
+
const seen = new Set(result.hits.map(hit => organizerKey(hit.provider, hit.session_id)))
|
|
713
|
+
const roots = agentSessionRoots()
|
|
714
|
+
const extra: AgentSessionSearchHit[] = []
|
|
715
|
+
for (const match of organizer.titleMatches(query)) {
|
|
716
|
+
if (seen.has(organizerKey(match.provider, match.session_id)) || extra.length + result.hits.length >= limit) continue
|
|
717
|
+
const file = await findAgentSessionFile(match.provider, match.session_id, roots).catch(() => null)
|
|
718
|
+
const short = file ? await shortRowFor(match.provider, file, undefined, roots) : null
|
|
719
|
+
if (!short) continue
|
|
720
|
+
extra.push({ session_id: short.session_id, provider: short.provider, display_label: short.display_label, project: short.project,
|
|
721
|
+
modified: short.modified, created: short.created, alive: false, state: 'recent', pinned: false,
|
|
722
|
+
snippet: '', keywordScore: 1, semanticScore: 0, match: 'keyword' })
|
|
723
|
+
}
|
|
724
|
+
result.hits = [...extra, ...result.hits]
|
|
725
|
+
}
|
|
622
726
|
const queuedOf = await allQueuedWaitingLookup(Date.now())
|
|
623
727
|
// 6.48.1: search hits carry the same derived state as list rows (no list/search shape
|
|
624
728
|
// drift), from the registry and the hook signal; a hit has no transcript walk.
|
|
@@ -644,7 +748,7 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
644
748
|
// 7.0.4 S1: search hits carry snippets of the same words.
|
|
645
749
|
res.json(redactLocalPathsDeep({
|
|
646
750
|
...result,
|
|
647
|
-
hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
|
|
751
|
+
hits: result.hits.map(hit => ({ ...toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit)), ...organizerFieldsOf(organizer, hit.provider, hit.session_id) })),
|
|
648
752
|
}))
|
|
649
753
|
} catch (error) {
|
|
650
754
|
console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
|
10
10
|
import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapability } from './dictation-polish-settings.js'
|
|
11
11
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
12
12
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
13
|
+
import { modelEfforts } from '../lib/model-efforts.js'
|
|
13
14
|
import { Router } from 'express'
|
|
14
15
|
import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
|
|
15
16
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
@@ -712,6 +713,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
712
713
|
...cursorOptions,
|
|
713
714
|
...ollamaOptions,
|
|
714
715
|
],
|
|
716
|
+
// Step 2a (Sessions chat-style layout): each agent's efforts, the values POST /api/query-jobs accepts. Additive.
|
|
717
|
+
efforts: modelEfforts(),
|
|
715
718
|
cursor: cursorCatalog,
|
|
716
719
|
cursorReady: isCursorProviderReady(),
|
|
717
720
|
ollama: {
|