@heixui/dsh-session-suite 0.0.0-stage → 0.3.1

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/lib/service.js ADDED
@@ -0,0 +1,1273 @@
1
+ /**
2
+ * The suite's operations, implemented once for both callers (the plugin UI over
3
+ * HTTP and the agent tools that call the same methods).
4
+ *
5
+ * Rollback is the interesting one. DSH's session log is append-only, so
6
+ * "return to this message" cannot delete events: it forks the exact event
7
+ * prefix into a fresh session, restores the workspace snapshot recorded for
8
+ * that turn, trims the memory ledger to the same sequence, and retires the old
9
+ * session. Every step is recorded in an undo bundle first, so one click can put
10
+ * all three back.
11
+ */
12
+
13
+ import fs from 'node:fs/promises'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { buildOutline, curateEvents, exportFileName, renderBlockDoc, renderHtmlFromDoc, renderJson, renderMarkdown, renderText, selectEvents } from './share.js'
17
+ import { asString, ensureDir, formatBytes, readJson, shortId, writeJson } from './util.js'
18
+
19
+ const UNDO_TTL_MS = 10 * 60 * 1000
20
+
21
+ export class SessionSuiteService {
22
+ /**
23
+ * @param {object} options service options
24
+ * @param {any} options.ctx Cordis context of the plugin
25
+ * @param {string} options.root suite data directory
26
+ * @param {any} options.store checkpoint store
27
+ * @param {any} options.memory memory ledger
28
+ * @param {(message: string, error?: unknown) => void} options.log logger
29
+ * @param {any} [options.config] plugin config
30
+ */
31
+ constructor({ ctx, root, store, memory, log, config }) {
32
+ this.ctx = ctx
33
+ this.root = root
34
+ this.store = store
35
+ this.memory = memory
36
+ this.log = log
37
+ this.config = config ?? {}
38
+ this.dshHome = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
39
+ this.undoDir = path.join(root, 'undo')
40
+ }
41
+
42
+ /** @param {string} name Host service name */
43
+ #service(name) {
44
+ return this.ctx.get(name)
45
+ }
46
+
47
+ #require(name) {
48
+ const service = this.ctx.get(name)
49
+ if (!service) throw new Error(`the ${name} service is not mounted in this profile`)
50
+ return service
51
+ }
52
+
53
+ /**
54
+ * @param {string} sessionId session
55
+ * @returns {Promise<{ meta: any, events: any[], title?: string, cwd?: string }>} inspection
56
+ */
57
+ async inspect(sessionId) {
58
+ const controller = this.#require('sessionController')
59
+ const inspection = await controller.inspect(sessionId)
60
+ if (!inspection) throw new Error(`session ${sessionId} was not found`)
61
+ const events = Array.isArray(inspection.events) ? inspection.events : []
62
+ return { meta: inspection.meta ?? { id: sessionId }, events, title: titleOf(events), cwd: inspection.meta?.cwd }
63
+ }
64
+
65
+ /**
66
+ * @returns {Promise<any[]>} visible sessions with suite annotations
67
+ */
68
+ async listSessions() {
69
+ const controller = this.#service('sessionController')
70
+ if (!controller?.list) return []
71
+ const value = await controller.list({})
72
+ const items = Array.isArray(value?.items) ? value.items : []
73
+ const out = []
74
+ for (const item of items) {
75
+ // Hide subagent children only. They are not conversations of their own —
76
+ // the sidebar hides them and this panel must not offer them as a session
77
+ // to snapshot.
78
+ //
79
+ // A `parentSessionId` is NOT enough to identify one. Every rewind, message
80
+ // delete, and segment delete forks the conversation and retires the
81
+ // original, so the session a user is actually talking in carries a parent
82
+ // too — it is a continuation of the same conversation, not a subagent.
83
+ // Filtering on the parent alone hid the live session from this panel and
84
+ // left it showing whichever unrelated session happened to have snapshots
85
+ // (「只有查询插件开发记录这个会话的快照」).
86
+ //
87
+ // `origin: 'subagent'` is the field that distinguishes them: SessionSummary
88
+ // declares it for exactly this purpose, while `parentSessionId` only
89
+ // describes lineage. The header also carries `delegationDepth`, but the
90
+ // summary does not project it, so it cannot be used here.
91
+ if (item.origin === 'subagent') continue
92
+ const checkpoints = await this.store.list(item.sessionId).catch(() => [])
93
+ const stats = await this.store.stats(item.sessionId).catch(() => undefined)
94
+ out.push({
95
+ sessionId: item.sessionId,
96
+ cwd: item.cwd,
97
+ running: item.running === true,
98
+ updatedAt: item.updatedAt,
99
+ title: titleFromProjections(item.projections) ?? item.cwd ?? item.sessionId,
100
+ checkpoints: checkpoints.length,
101
+ checkpointBytes: stats?.bytes ?? 0,
102
+ checkpointLabel: formatBytes(stats?.bytes ?? 0),
103
+ })
104
+ }
105
+ return out
106
+ }
107
+
108
+ /**
109
+ * Turn-level outline for the share and segment dialogs.
110
+ *
111
+ * @param {string} sessionId session
112
+ * @returns {Promise<any>} outline
113
+ */
114
+ async outline(sessionId) {
115
+ const inspection = await this.inspect(sessionId)
116
+ const turns = buildOutline(inspection.events)
117
+ return {
118
+ sessionId,
119
+ title: inspection.title,
120
+ turns,
121
+ totalTurns: turns.length,
122
+ totalEvents: inspection.events.length,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Everything the settings panel needs for one session.
128
+ *
129
+ * @param {string} sessionId session
130
+ * @returns {Promise<any>} detail payload
131
+ */
132
+ async detail(sessionId) {
133
+ const checkpoints = await this.store.list(sessionId)
134
+ const stats = await this.store.stats(sessionId)
135
+ const memory = await this.memory.list(sessionId)
136
+ let inspection
137
+ try {
138
+ inspection = await this.inspect(sessionId)
139
+ } catch {
140
+ inspection = undefined
141
+ }
142
+ return {
143
+ sessionId,
144
+ title: inspection?.title ?? undefined,
145
+ cwd: inspection?.cwd,
146
+ checkpoints: checkpoints.map(decorateCheckpoint),
147
+ memory,
148
+ stats: {
149
+ bytes: stats.bytes,
150
+ label: formatBytes(stats.bytes),
151
+ checkpoints: stats.checkpoints,
152
+ budget: this.config.keep ?? 10,
153
+ },
154
+ undos: await this.listUndos(sessionId),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Take a checkpoint on demand.
160
+ *
161
+ * @param {string} sessionId session
162
+ * @param {{ label?: string, kind?: 'auto' | 'manual' }} [options] options
163
+ * @returns {Promise<any>} checkpoint summary
164
+ */
165
+ async capture(sessionId, options = {}) {
166
+ const inspection = await this.inspect(sessionId)
167
+ const cwd = inspection.cwd
168
+ if (!cwd) throw new Error('this session has no workspace directory to snapshot')
169
+ const last = (await this.store.list(sessionId))[0]
170
+ const result = await this.store.capture({
171
+ sessionId,
172
+ cwd,
173
+ seq: lastSeqOf(inspection.events),
174
+ // A manual capture describes the last turn that actually finished, so it
175
+ // keeps the same meaning as an automatic one ("the workspace after turn N").
176
+ turn: lastCompletedTurn(inspection.events),
177
+ phase: 'end',
178
+ kind: options.kind ?? 'manual',
179
+ label: options.label,
180
+ limits: limitsFrom(this.config),
181
+ })
182
+ return { ...decorateCheckpoint(result.checkpoint), skipped: result.skipped.slice(0, 20), truncated: result.truncated }
183
+ }
184
+
185
+ /**
186
+ * Restore files only — the snapshot half of a rollback, usable on its own.
187
+ *
188
+ * @param {string} sessionId session
189
+ * @param {string} checkpointId checkpoint
190
+ * @returns {Promise<any>} restore outcome
191
+ */
192
+ async restoreCheckpoint(sessionId, checkpointId) {
193
+ const outcome = await this.store.restore({ sessionId, checkpointId })
194
+ return { ...outcome, message: `restored ${outcome.written} files, removed ${outcome.deleted}` }
195
+ }
196
+
197
+ /**
198
+ * Full rollback: conversation prefix + workspace files + memory ledger.
199
+ *
200
+ * @param {object} input request
201
+ * @param {string} input.sessionId source session
202
+ * @param {string} [input.messageId] durable message id of the target step
203
+ * @param {number} [input.seq] durable sequence of the target event
204
+ * @param {number} [input.turn] turn of the target message
205
+ * @returns {Promise<any>} rollback outcome including the new session id
206
+ */
207
+ async rollback({ sessionId, messageId, seq, turn }) {
208
+ const inspection = await this.inspect(sessionId)
209
+ const cwd = inspection.cwd
210
+ const controller = this.#require('sessionController')
211
+ let targetSeq = Number.isFinite(seq) ? Number(seq) : undefined
212
+ let targetTurn = Number.isFinite(turn) ? Number(turn) : undefined
213
+ if (targetSeq === undefined && typeof messageId === 'string') {
214
+ const found = findMessage(inspection.events, messageId)
215
+ if (!found) throw new Error('that message is not part of this session')
216
+ targetSeq = found.seq
217
+ targetTurn = found.turn
218
+ }
219
+ if (targetSeq === undefined) targetSeq = lastSeqOf(inspection.events)
220
+ if (targetTurn === undefined) targetTurn = turnOfSeq(inspection.events, targetSeq)
221
+
222
+ // A snapshot records the state *after* its turn. Rewinding to a reply keeps
223
+ // that turn's work; rewinding to the prompt means "before this turn", i.e.
224
+ // the state after the previous one.
225
+ const targetEvent = inspection.events.find(event => event.seq === targetSeq)
226
+ const targetRole = targetEvent?.type === 'user/message' ? 'user' : 'assistant'
227
+ const restoreTurn = targetRole === 'user' ? (targetTurn ?? 1) - 1 : targetTurn
228
+
229
+ // 1. Protect the current state before touching anything.
230
+ const undoId = `undo_${shortId(`${sessionId}:${targetSeq}`)}`
231
+ const undo = {
232
+ id: undoId,
233
+ sessionId,
234
+ seq: targetSeq,
235
+ turn: targetTurn ?? null,
236
+ createdAt: Date.now(),
237
+ expiresAt: Date.now() + UNDO_TTL_MS,
238
+ memory: await this.memory.snapshot(sessionId),
239
+ checkpointId: null,
240
+ cwd,
241
+ status: 'pending',
242
+ }
243
+ if (cwd) {
244
+ try {
245
+ const captured = await this.store.capture({
246
+ sessionId,
247
+ cwd,
248
+ seq: targetSeq,
249
+ turn: targetTurn,
250
+ kind: 'pre-rollback',
251
+ label: 'before rollback',
252
+ ttlMs: UNDO_TTL_MS,
253
+ limits: limitsFrom(this.config),
254
+ })
255
+ undo.checkpointId = captured.checkpoint.id
256
+ } catch (error) {
257
+ this.log('pre-rollback snapshot failed; aborting rollback', error)
258
+ throw new Error(`could not snapshot the workspace before rollback: ${errorMessage(error)}`)
259
+ }
260
+ }
261
+ await this.#writeUndo(undo)
262
+
263
+ // 2. Fork the exact event prefix — the conversation half of the rollback.
264
+ let childId
265
+ try {
266
+ const forked = await controller.fork({ sessionId, atSeq: targetSeq })
267
+ childId = forked?.sessionId
268
+ if (!childId) throw new Error('fork returned no session id')
269
+ } catch (error) {
270
+ await this.#discardUndo(undoId)
271
+ throw new Error(`could not fork the session at seq ${targetSeq}: ${errorMessage(error)}`)
272
+ }
273
+ undo.childId = childId
274
+ undo.status = 'forked'
275
+ await this.#writeUndo(undo)
276
+
277
+ // 3. Workspace files back to the state recorded for that turn.
278
+ let restore = undefined
279
+ const checkpoint = await this.store.checkpointAt(sessionId, restoreTurn, targetSeq)
280
+ if (checkpoint) {
281
+ try {
282
+ restore = await this.store.restore({ sessionId, checkpointId: checkpoint.id })
283
+ } catch (error) {
284
+ this.log(`restore of ${checkpoint.id} failed after fork; undo remains available`, error)
285
+ restore = { error: errorMessage(error) }
286
+ }
287
+ }
288
+
289
+ // 4. Memory ledger back to the same sequence, and carried into the child so
290
+ // the continued conversation keeps the facts that survive the rewind.
291
+ const trimmed = await this.memory.trimTo(sessionId, targetSeq).catch(() => ({ removed: 0, kept: 0 }))
292
+ const copied = await this.memory.clone(sessionId, childId, targetSeq).catch(() => ({ copied: 0 }))
293
+
294
+ // 4b. Snapshots up to the rewind point move to the child. The parent is
295
+ // retired, so its snapshots would otherwise become unreachable from the
296
+ // conversation the user actually continues.
297
+ const inherited = await this.store
298
+ .inherit({ from: sessionId, to: childId, throughSeq: targetSeq, keep: this.config.keep })
299
+ .catch(error => {
300
+ this.log(`could not inherit checkpoints into ${childId}`, error)
301
+ return { inherited: 0, skipped: 0 }
302
+ })
303
+
304
+ // 5. Retire the old session from the sidebar (files stay; the undo bundle owns the revert).
305
+ const archived = await this.#archive(sessionId, true)
306
+
307
+ undo.status = 'done'
308
+ undo.restore = restore ?? null
309
+ undo.archived = archived
310
+ await this.#writeUndo(undo)
311
+
312
+ return {
313
+ undoId,
314
+ childId,
315
+ sessionId,
316
+ seq: targetSeq,
317
+ turn: targetTurn ?? null,
318
+ checkpoint: checkpoint ? decorateCheckpoint(checkpoint) : null,
319
+ restore: restore ?? null,
320
+ memory: { ...trimmed, copied: copied.copied },
321
+ checkpoints: inherited,
322
+ archived,
323
+ expiresInMs: UNDO_TTL_MS,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Cut the conversation at a sequence without touching the workspace.
329
+ *
330
+ * Deleting conversation content is the one operation that must not restore
331
+ * files: the workspace keeps whatever the session produced, and only the log
332
+ * and the memory ledger are trimmed. The append-only log cannot remove events
333
+ * in the middle, so this forks the prefix and retires the original — the same
334
+ * primitive as a rewind, minus the snapshot restore.
335
+ *
336
+ * @param {object} input request
337
+ * @param {string} input.sessionId session
338
+ * @param {number} [input.keepThroughSeq] last sequence to keep; omitted keeps nothing (empty session)
339
+ * @returns {Promise<any>} outcome
340
+ */
341
+ async truncateConversation({ sessionId, keepThroughSeq }) {
342
+ const inspection = await this.inspect(sessionId)
343
+ const controller = this.#require('sessionController')
344
+ let childId
345
+ if (Number.isFinite(keepThroughSeq) && keepThroughSeq >= 0) {
346
+ const forked = await controller.fork({ sessionId, atSeq: keepThroughSeq })
347
+ childId = forked?.sessionId
348
+ } else {
349
+ const created = await controller.create({ cwd: inspection.cwd })
350
+ childId = created?.sessionId
351
+ }
352
+ if (!childId) throw new Error('could not create the trimmed conversation')
353
+
354
+ const bound = Number.isFinite(keepThroughSeq) ? keepThroughSeq : -1
355
+ const trimmed = await this.memory.trimTo(sessionId, bound).catch(() => ({ removed: 0, kept: 0 }))
356
+ const copied = await this.memory.clone(sessionId, childId, bound).catch(() => ({ copied: 0 }))
357
+ // The child continues the parent's conversation, so it must also continue
358
+ // its snapshot history: without this the panel is empty and rewinding in the
359
+ // continuation has no earlier state to restore.
360
+ const inherited = await this.store
361
+ .inherit({ from: sessionId, to: childId, throughSeq: keepThroughSeq, keep: this.config.keep })
362
+ .catch(error => {
363
+ this.log(`could not inherit checkpoints into ${childId}`, error)
364
+ return { inherited: 0, skipped: 0 }
365
+ })
366
+ const archived = await this.#archive(sessionId, true)
367
+ return { childId, sessionId, keepThroughSeq: Number.isFinite(keepThroughSeq) ? keepThroughSeq : null, memory: { ...trimmed, copied: copied.copied }, checkpoints: inherited, archived, filesUntouched: true }
368
+ }
369
+
370
+ /**
371
+ * Delete one message: the message and everything after it leave the
372
+ * conversation, while the workspace files stay exactly as they are.
373
+ *
374
+ * @param {object} input request
375
+ * @param {string} input.sessionId session
376
+ * @param {string} input.messageId durable message id
377
+ * @returns {Promise<any>} outcome
378
+ */
379
+ async deleteMessage({ sessionId, messageId }) {
380
+ const inspection = await this.inspect(sessionId)
381
+ const target = findMessage(inspection.events, messageId)
382
+ if (!target) throw new Error('that message is not part of this session')
383
+ // Deleting a message removes its whole turn: the prompt, the reply, and the
384
+ // narration between them. Cutting at the message before the prompt does that
385
+ // in one move, and files are never touched (only a rewind restores files).
386
+ const promptSeq = target.role === 'assistant' ? previousUserSeq(inspection.events, target.seq) : target.seq
387
+ const keep = promptSeq === undefined ? undefined : previousMessageSeq(inspection.events, promptSeq)
388
+ const result = await this.truncateConversation({ sessionId, keepThroughSeq: keep })
389
+ return { deletedSession: false, fromSeq: target.seq, ...result }
390
+ }
391
+
392
+ /**
393
+ * Delete a selected run of turns. Files are never touched; memory entries
394
+ * observed inside the removed range are dropped with it.
395
+ *
396
+ * @param {object} input request
397
+ * @param {string} input.sessionId session
398
+ * @param {number} input.startSeq first sequence of the removed range
399
+ * @returns {Promise<any>} outcome
400
+ */
401
+ async deleteSegment({ sessionId, startSeq }) {
402
+ const inspection = await this.inspect(sessionId)
403
+ const seq = Number(startSeq)
404
+ if (!Number.isFinite(seq)) throw new Error('startSeq is required')
405
+ const previous = previousMessageSeq(inspection.events, seq)
406
+ const result = await this.truncateConversation({ sessionId, keepThroughSeq: previous })
407
+ return { deletedSession: false, fromSeq: seq, ...result }
408
+ }
409
+
410
+ /**
411
+ * Resend a turn's prompt in a fork of the conversation.
412
+ *
413
+ * This is the "retry" a failed or unsatisfactory turn needs: the conversation
414
+ * is forked just before that turn's prompt and the same prompt is sent again
415
+ * in the fork, so the model answers afresh instead of being asked to continue
416
+ * on top of an answer the user rejected. The original session is kept as a
417
+ * branch, the durable log is never rewritten, and — unlike a rewind — **files
418
+ * are not touched**: retrying a reply is a conversation operation, and
419
+ * restoring the workspace would silently discard work done since.
420
+ *
421
+ * `mode: 'stage'` forks and stops there, returning the prompt text so the
422
+ * client can put it in the composer for the user to edit before sending. That
423
+ * is what "edit and resend" needs, and it avoids sending a prompt the user is
424
+ * about to change.
425
+ *
426
+ * @param {object} input request
427
+ * @param {string} input.sessionId session
428
+ * @param {number} input.turn turn whose prompt to resend
429
+ * @param {'retry' | 'stage'} [input.mode] retry sends it; stage only returns it
430
+ * @param {string} [input.text] replacement prompt text (defaults to the original)
431
+ * @returns {Promise<any>} the fork, the prompt that was or will be sent, and whether it was sent
432
+ */
433
+ async resendTurn({ sessionId, turn, messageId, mode = 'retry', text }) {
434
+ const inspection = await this.inspect(sessionId)
435
+
436
+ // The chat slots hand the client a message id or a turn location, and the
437
+ // location's shape is not part of a stable contract, so resolve the turn
438
+ // here instead of trusting the caller to have found a number.
439
+ let target = Number(turn)
440
+ if (!Number.isFinite(target) && typeof messageId === 'string') {
441
+ target = findMessage(inspection.events, messageId)?.turn
442
+ }
443
+ if (!Number.isFinite(target)) {
444
+ // No turn named at all: resend the latest turn that a user prompt started.
445
+ // That is what "edit my last question" means, and it keeps the row usable
446
+ // even when the location shape is unknown.
447
+ target = lastUserTurn(inspection.events)
448
+ }
449
+ if (!Number.isFinite(target)) throw new Error('there is no prompt in this session to resend')
450
+
451
+ const prompt = promptOfTurn(inspection.events, target)
452
+ if (!prompt) throw new Error(`turn ${target} was not started by a prompt you sent`)
453
+ const payload = typeof text === 'string' && text.trim() ? text.trim() : prompt.text
454
+
455
+ // Resend happens in the SAME session: no fork, no new workspace, no new
456
+ // sidebar row. The durable log is append-only, so the previous reply cannot
457
+ // be erased; instead we drop the memories that turn recorded and append the
458
+ // edited question, which starts a fresh turn whose answer stands on its own.
459
+ // Snapshots are deliberately untouched — the user asked for this to be a
460
+ // conversation operation, and restoring files would discard work done since.
461
+ const trimmed = await this.memory.trimTo(sessionId, prompt.seq - 1).catch(() => ({ removed: 0, kept: 0 }))
462
+ if (mode === 'stage') {
463
+ return { sessionId, turn: target, prompt: payload, originalPrompt: prompt.text, sent: false, memory: trimmed, filesUntouched: true }
464
+ }
465
+
466
+ await this.#promptInSession(sessionId, payload)
467
+ return {
468
+ sessionId,
469
+ turn: target,
470
+ prompt: payload,
471
+ originalPrompt: prompt.text,
472
+ sent: true,
473
+ memory: trimmed,
474
+ filesUntouched: true,
475
+ // No childId: there is no new session to switch to.
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Read the prompt that would be resent, without forking or writing anything.
481
+ *
482
+ * The edit dialog needs the original text to pre-fill its box. Using
483
+ * `mode: 'stage'` for that was wrong: stage still forks, so merely opening the
484
+ * dialog created a branch (and could fail for reasons unrelated to reading a
485
+ * sentence). A preview is a pure read.
486
+ *
487
+ * @param {object} input request
488
+ * @param {string} input.sessionId session
489
+ * @param {number} [input.turn] turn
490
+ * @param {string} [input.messageId] message id
491
+ * @returns {Promise<{ turn: number, prompt: string }>} the prompt to edit
492
+ */
493
+ async previewResend({ sessionId, turn, messageId }) {
494
+ const inspection = await this.inspect(sessionId)
495
+ let target = Number(turn)
496
+ if (!Number.isFinite(target) && typeof messageId === 'string') {
497
+ target = findMessage(inspection.events, messageId)?.turn
498
+ }
499
+ if (!Number.isFinite(target)) target = lastUserTurn(inspection.events)
500
+ if (!Number.isFinite(target)) throw new Error('there is no prompt in this session to resend')
501
+ const prompt = promptOfTurn(inspection.events, target)
502
+ if (!prompt) throw new Error(`turn ${target} was not started by a prompt you sent`)
503
+ return { turn: target, prompt: prompt.text }
504
+ }
505
+
506
+ /**
507
+ * Admit one prompt into a session.
508
+ *
509
+ * Kept private and narrow: the only caller is a resend, which has just
510
+ * created the session and therefore knows it is idle.
511
+ *
512
+ * @param {string} sessionId session to prompt
513
+ * @param {string} text prompt text
514
+ */
515
+ async #promptInSession(sessionId, text) {
516
+ const controller = this.#require('sessionController')
517
+ if (typeof controller.prompt !== 'function') {
518
+ throw new Error('this Host build cannot accept a prompt programmatically')
519
+ }
520
+ const requestId = `resend-${shortId(`${sessionId}:${Date.now()}`)}`
521
+ const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
522
+ const request = {
523
+ requestId,
524
+ sessionId,
525
+ mode: 'queue',
526
+ content: [{ type: 'text', text }],
527
+ }
528
+ // The Remote signature is `prompt(request, signal)`. The Host reads
529
+ // `signal.throwIfAborted()` while admitting the prompt, so omitting the
530
+ // second argument makes it dereference `undefined` — reported as
531
+ // "Cannot read properties of undefined (reading 'throwIfAborted')".
532
+ const result = signal === undefined
533
+ ? await controller.prompt(request)
534
+ : await controller.prompt(request, signal)
535
+ if (result && result.accepted === false) throw new Error('the Host rejected the resent prompt')
536
+ }
537
+
538
+ /**
539
+ * Undo a rollback: bring back the files, the memory entries, and the session.
540
+ *
541
+ * @param {string} undoId undo bundle id
542
+ * @returns {Promise<any>} undo outcome
543
+ */
544
+ async undo(undoId) {
545
+ const undo = await readJson(path.join(this.undoDir, `${undoId}.json`))
546
+ if (!undo) throw new Error('this undo bundle no longer exists')
547
+ if (undo.undoneAt) throw new Error('this rollback was already undone')
548
+ let restore = undefined
549
+ if (undo.checkpointId) {
550
+ restore = await this.store.restore({ sessionId: undo.sessionId, checkpointId: undo.checkpointId })
551
+ }
552
+ if (undo.memory) await this.memory.restore(undo.sessionId, undo.memory)
553
+ await this.#unarchive(undo.sessionId)
554
+ if (undo.childId) await this.#archive(undo.childId, true)
555
+ undo.undoneAt = Date.now()
556
+ await this.#writeUndo(undo)
557
+ return { sessionId: undo.sessionId, childId: undo.childId, restore: restore ?? null }
558
+ }
559
+
560
+ /**
561
+ * @param {string} sessionId session
562
+ * @returns {Promise<any[]>} live undo bundles
563
+ */
564
+ async listUndos(sessionId) {
565
+ const dir = this.undoDir
566
+ let names = []
567
+ try {
568
+ names = (await fs.readdir(dir)).filter(name => name.endsWith('.json'))
569
+ } catch {
570
+ return []
571
+ }
572
+ const out = []
573
+ for (const name of names) {
574
+ const undo = await readJson(path.join(dir, name)).catch(() => undefined)
575
+ if (!undo || undo.undoneAt) continue
576
+ if (undo.expiresAt && undo.expiresAt <= Date.now()) continue
577
+ if (sessionId && undo.sessionId === sessionId) out.push(undo)
578
+ else if (!sessionId) out.push(undo)
579
+ }
580
+ return out.sort((a, b) => b.createdAt - a.createdAt)
581
+ }
582
+
583
+ /**
584
+ * What a delete would remove, without removing anything.
585
+ *
586
+ * A running session is refused rather than killed, so a caller can warn about
587
+ * that — and about the subagents a cascade would take with it — before it
588
+ * commits to the irreversible half.
589
+ *
590
+ * @param {string} sessionId session
591
+ * @returns {Promise<{ sessionId: string, title?: string, running: boolean, subagents: { sessionId: string, title?: string, running: boolean }[], totalSessions: number, willRefuse: boolean, refuseReason: string | null }>} preview
592
+ */
593
+ async previewDelete(sessionId) {
594
+ const controller = this.#require('sessionController')
595
+ const list = await controller.list({}).catch(() => ({ items: [] }))
596
+ const items = Array.isArray(list?.items) ? list.items : []
597
+ const byId = new Map(items.filter(item => item?.sessionId).map(item => [item.sessionId, item]))
598
+ const summary = byId.get(sessionId)
599
+ const subagents = subagentDescendants(items, sessionId).map(id => {
600
+ const item = byId.get(id) ?? { sessionId: id }
601
+ return { sessionId: id, title: titleFromProjections(item.projections) ?? item.cwd ?? id, running: item.running === true }
602
+ })
603
+ // `willRefuse` answers one question only: would deleteSession refuse this
604
+ // because something is still running? That refusal is the whole safety
605
+ // contract, and the reason says whether it is the session itself or one of
606
+ // its subagents that has to stop first.
607
+ let refuseReason = null
608
+ if (summary?.running === true) refuseReason = 'session-running'
609
+ else if (subagents.some(child => child.running)) refuseReason = 'subagent-running'
610
+ return {
611
+ sessionId,
612
+ title: summary ? titleFromProjections(summary.projections) ?? summary.cwd ?? sessionId : undefined,
613
+ running: summary?.running === true,
614
+ subagents,
615
+ totalSessions: 1 + subagents.length,
616
+ willRefuse: refuseReason !== null,
617
+ refuseReason,
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Permanently delete a session: sidebar entry, log, projection cache,
623
+ * checkpoints, and memory.
624
+ *
625
+ * By default this also cascades to the session's subagent descendants. A
626
+ * subagent is not a conversation anyone can open, so leaving one behind only
627
+ * strands its snapshots and memory under an id nothing links to any more.
628
+ *
629
+ * Two rules keep the cascade safe:
630
+ *
631
+ * 1. Validate all, then touch: every id is checked before the first byte is
632
+ * removed, so a single running session — the session itself or any
633
+ * descendant — refuses the whole operation with `code: 'session-running'`
634
+ * and nothing at all is deleted. A running session is never killed to make
635
+ * deletion possible.
636
+ * 2. Descendants first, the session last, so a child never outlives its
637
+ * parent. A descendant that fails to delete is reported in `refused`
638
+ * instead of silently truncating the tree.
639
+ *
640
+ * Identity is decided by `origin === 'subagent'` alone. A `parentSessionId`
641
+ * proves nothing: every rewind and delete forks the conversation, so the
642
+ * session a user is actually talking in carries a parent too and must never
643
+ * be swept into a cascade (docs/pitfalls.md #48).
644
+ *
645
+ * @param {string} sessionId session
646
+ * @param {{ cascade?: boolean }} [options] options; `cascade: false` deletes the session alone
647
+ * @returns {Promise<any>} what was removed, plus `removedSubagents` and `refused`
648
+ */
649
+ async deleteSession(sessionId, options = {}) {
650
+ const controller = this.#require('sessionController')
651
+ const cascade = options?.cascade !== false
652
+ const list = await controller.list({}).catch(() => ({ items: [] }))
653
+ const items = Array.isArray(list?.items) ? list.items : []
654
+ const byId = new Map(items.filter(item => item?.sessionId).map(item => [item.sessionId, item]))
655
+
656
+ // Deepest descendant first, the session itself last, so this doubles as the
657
+ // delete order.
658
+ const descendants = cascade ? subagentDescendants(items, sessionId) : []
659
+
660
+ // 1. Validate every id before touching anything.
661
+ for (const id of [sessionId, ...descendants]) {
662
+ if (byId.get(id)?.running === true) throw sessionRunningError(id)
663
+ }
664
+
665
+ // 2. Delete deepest-first. Partial failure is reported, never hidden.
666
+ const removedSubagents = []
667
+ const refused = []
668
+ for (const id of descendants) {
669
+ try {
670
+ const removed = await this.#purgeSession(id)
671
+ if (removed.sessionFiles.length === 0) {
672
+ refused.push({ sessionId: id, reason: 'no stored session files were found to delete' })
673
+ } else {
674
+ removedSubagents.push({ sessionId: id, ...removed })
675
+ }
676
+ } catch (error) {
677
+ this.log(`could not delete subagent ${id}`, error)
678
+ refused.push({ sessionId: id, reason: errorMessage(error) })
679
+ }
680
+ }
681
+
682
+ const removed = await this.#purgeSession(sessionId)
683
+ if (removed.sessionFiles.length === 0) {
684
+ throw new Error('no stored session files were found to delete (the Session may already be gone)')
685
+ }
686
+ // Tell the live layer the session is gone: without this the sidebar keeps the
687
+ // row until the app restarts, even though the files no longer exist.
688
+ try {
689
+ this.ctx.emit?.('api-session/removed', sessionId)
690
+ } catch (error) {
691
+ this.log('could not announce the deleted session', error)
692
+ }
693
+ const stillListed = await controller
694
+ .list({})
695
+ .then(value => (value?.items ?? []).some(item => item.sessionId === sessionId))
696
+ .catch(() => false)
697
+ return { sessionId, ...removed, removedSubagents, refused, stillListed }
698
+ }
699
+
700
+ /**
701
+ * Permanently delete several sessions in one request.
702
+ *
703
+ * The value here is not speed — each session is still removed by the same
704
+ * path as a single delete. It is the *reporting*: with a batch, one running
705
+ * session must not abort the whole cleanup, and the caller has to learn which
706
+ * ids went and which did not. So every id is validated up front, then each is
707
+ * deleted independently, and the result names both sets explicitly.
708
+ *
709
+ * A running session is skipped and reported, never killed (docs/pitfalls.md
710
+ * on why: killing a live turn to satisfy a delete is worse than refusing).
711
+ *
712
+ * @param {readonly string[]} sessionIds sessions to delete
713
+ * @param {{ cascade?: boolean }} [options] passed through to deleteSession
714
+ * @returns {Promise<{ removed: string[], failed: { sessionId: string, reason: string }[], requested: number }>}
715
+ */
716
+ async deleteSessions(sessionIds, options = {}) {
717
+ const requested = Array.isArray(sessionIds) ? [...new Set(sessionIds.filter(id => typeof id === 'string' && id))] : []
718
+ if (requested.length === 0) return { removed: [], failed: [], requested: 0 }
719
+
720
+ // Cap the batch. An unbounded list is usually a caller bug, and a partial
721
+ // apply would be far more confusing than a clear refusal.
722
+ const MAX_BATCH = 200
723
+ if (requested.length > MAX_BATCH) {
724
+ throw new Error(`cannot delete more than ${MAX_BATCH} sessions at once (asked for ${requested.length})`)
725
+ }
726
+
727
+ const removed = []
728
+ const failed = []
729
+ for (const sessionId of requested) {
730
+ try {
731
+ await this.deleteSession(sessionId, options)
732
+ removed.push(sessionId)
733
+ } catch (error) {
734
+ // One failure never stops the batch: the point of a batch is to clean up
735
+ // what can be cleaned up.
736
+ this.log(`could not delete ${sessionId}`, error)
737
+ failed.push({ sessionId, reason: errorMessage(error) })
738
+ }
739
+ }
740
+ return { removed, failed, requested: requested.length }
741
+ }
742
+
743
+ /**
744
+ * Remove every trace of one session from disk and from the suite's stores.
745
+ *
746
+ * Callers validate first: this only reports what it removed, it never decides
747
+ * whether it was allowed to run.
748
+ *
749
+ * @param {string} sessionId session
750
+ * @returns {Promise<{ sessionFiles: string[], projectionCache: string[], checkpoints: boolean, memory: boolean }>} what was removed
751
+ */
752
+ async #purgeSession(sessionId) {
753
+ // Retire it first so the sidebar stops offering it while files disappear.
754
+ await this.#archive(sessionId, true)
755
+
756
+ const removed = { sessionFiles: [], projectionCache: [], checkpoints: false, memory: false }
757
+ for (const dir of await this.#sessionDirs(sessionId)) {
758
+ try {
759
+ await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
760
+ removed.sessionFiles.push(dir)
761
+ } catch (error) {
762
+ this.log(`could not remove ${dir}`, error)
763
+ }
764
+ }
765
+ const cacheFile = path.join(this.dshHome, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
766
+ try {
767
+ await fs.rm(cacheFile, { force: true, maxRetries: 3, retryDelay: 200 })
768
+ removed.projectionCache.push(cacheFile)
769
+ } catch (error) {
770
+ this.log(`could not remove ${cacheFile}`, error)
771
+ }
772
+ await this.store.purgeSession(sessionId).catch(() => {})
773
+ removed.checkpoints = true
774
+ await this.memory.purge(sessionId).catch(() => {})
775
+ removed.memory = true
776
+ for (const undo of await this.listUndos(sessionId)) await this.#discardUndo(undo.id).catch(() => {})
777
+
778
+ // Finally, drop the archive mark itself.
779
+ //
780
+ // Archiving is the right *first* step — it retires the row while the files
781
+ // go away — but leaving the mark behind means the session id survives in the
782
+ // workspace registry after everything it named is gone. Other plugins read
783
+ // that registry, and one of them (dsh-context) keeps a per-session context
784
+ // cache that is only dropped on `session/disposed`. Nothing here disposes a
785
+ // session — the Host owns that lifecycle — so an id left in the archive set
786
+ // is an id a co-installed plugin can still resolve and redisplay. That is
787
+ // exactly the "deleted session came back" report: the files were gone, but
788
+ // the mark said otherwise.
789
+ //
790
+ // Unarchiving is safe here precisely because the id is a ghost: the files it
791
+ // referred to no longer exist, so removing the mark cannot restore anything.
792
+ await this.#unarchive(sessionId).catch(() => {})
793
+ return removed
794
+ }
795
+
796
+ /**
797
+ * @param {object} input request
798
+ * @param {string} input.sessionId session
799
+ * @param {'md' | 'txt' | 'json' | 'html' | 'blocks'} [input.format] export format
800
+ * @param {readonly { startSeq: number, endSeq: number }[]} [input.ranges] selected turns; omitted exports everything
801
+ * @param {'final' | 'all'} [input.mode] curation mode
802
+ * @returns {Promise<{ body: string, contentType: string, fileName: string, messages: number }>} export
803
+ */
804
+ async share({ sessionId, format = 'md', ranges, mode = 'final' }) {
805
+ const inspection = await this.inspect(sessionId)
806
+ const title = inspection.title
807
+ const events = selectEvents(inspection.events, ranges)
808
+ const curation = mode === 'all' ? 'all' : 'final'
809
+ const extension = format === 'json' || format === 'blocks' ? 'json' : format === 'txt' ? 'txt' : format === 'html' ? 'html' : 'md'
810
+ const fileName = exportFileName(title, sessionId, extension)
811
+ const messages = events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
812
+ if (format === 'blocks' || format === 'html') {
813
+ // Structured Markdown for the browser's canvas renderer: the Host parses
814
+ // once, so the image and the HTML page always agree.
815
+ const doc = await this.#withImages(renderBlockDoc({ sessionId, title, events, mode: curation }))
816
+ return {
817
+ body: format === 'html' ? renderHtmlFromDoc(doc) : JSON.stringify(doc),
818
+ contentType: format === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
819
+ fileName,
820
+ messages: doc.count ?? messages,
821
+ }
822
+ }
823
+ if (format === 'json') {
824
+ return {
825
+ body: renderJson({ sessionId, title, events: curation === 'final' ? curateEvents(events) : events }),
826
+ contentType: 'application/json; charset=utf-8',
827
+ fileName,
828
+ messages,
829
+ }
830
+ }
831
+ if (format === 'txt') {
832
+ return {
833
+ body: renderText({
834
+ sessionId,
835
+ title,
836
+ events: curation === 'final' ? curateEvents(events) : events,
837
+ options: { toolDetails: false },
838
+ }),
839
+ contentType: 'text/plain; charset=utf-8',
840
+ fileName,
841
+ messages,
842
+ }
843
+ }
844
+ return {
845
+ body: renderMarkdown({
846
+ sessionId,
847
+ title,
848
+ events: curation === 'final' ? curateEvents(events) : events,
849
+ options: { toolDetails: false, toolResults: false },
850
+ }),
851
+ contentType: 'text/markdown; charset=utf-8',
852
+ fileName,
853
+ messages,
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Remove expired undo bundles and enforce the per-session checkpoint budget.
859
+ */
860
+ async sweep() {
861
+ const undos = await this.listUndos('')
862
+ const now = Date.now()
863
+ for (const undo of undos) {
864
+ if (!undo.expiresAt || undo.expiresAt > now) continue
865
+ if (undo.checkpointId) {
866
+ await this.store.removeCheckpoint(undo.sessionId, undo.checkpointId).catch(() => {})
867
+ }
868
+ if (undo.childId) await this.#unarchive(undo.childId).catch(() => {})
869
+ await this.#discardUndo(undo.id).catch(() => {})
870
+ }
871
+ await this.store.sweep()
872
+ }
873
+
874
+ async #writeUndo(undo) {
875
+ await ensureDir(this.undoDir)
876
+ await writeJson(path.join(this.undoDir, `${undo.id}.json`), undo)
877
+ }
878
+
879
+ /**
880
+ * Inline the screenshots a session carries, so a shared page (and the canvas
881
+ * image built from the same document) shows the pictures instead of a marker.
882
+ *
883
+ * Attachments live outside the session log, which is why the export resolves
884
+ * them here: `attachments.imageHostPath` gives the on-disk file, and a bounded
885
+ * budget keeps a chatty conversation from producing a 50 MB page.
886
+ *
887
+ * @param {any} doc block document
888
+ * @returns {Promise<any>} the same document with data URLs
889
+ */
890
+ async #withImages(doc) {
891
+ const attachments = this.#service('attachments')
892
+ if (!attachments?.imageHostPath) return doc
893
+ const limit = numberOr(this.config.imageBudget, 12)
894
+ let remaining = limit
895
+ let bytes = 0
896
+ const maxBytes = numberOr(this.config.imageMaxBytes, 3 * 1024 * 1024)
897
+ for (const message of doc.messages ?? []) {
898
+ const resolved = []
899
+ for (const image of message.images ?? []) {
900
+ if (remaining <= 0 || bytes >= limit * maxBytes) continue
901
+ try {
902
+ const file = attachments.imageHostPath(image)
903
+ if (!file) continue
904
+ const info = await fs.stat(file)
905
+ if (!info.isFile() || info.size > maxBytes) continue
906
+ const data = await fs.readFile(file)
907
+ const mediaType = image.mediaType ?? 'image/png'
908
+ resolved.push({ ...image, dataUrl: `data:${mediaType};base64,${data.toString('base64')}` })
909
+ remaining -= 1
910
+ bytes += info.size
911
+ } catch (error) {
912
+ this.log(`could not inline ${image.attachmentId ?? 'an image'}`, error)
913
+ }
914
+ }
915
+ message.images = resolved
916
+ }
917
+ return doc
918
+ }
919
+
920
+ async #discardUndo(undoId) {
921
+ await fs.rm(path.join(this.undoDir, `${undoId}.json`), { force: true })
922
+ }
923
+
924
+ /** @param {string} sessionId session @param {boolean} stop stop its running work */
925
+ async #archive(sessionId, stop) {
926
+ const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
927
+ for (const controller of controllers) {
928
+ if (!controller?.archiveSession) continue
929
+ try {
930
+ await controller.archiveSession(sessionId, { stopActivity: stop === true })
931
+ return true
932
+ } catch (error) {
933
+ this.log('archiveSession failed', error)
934
+ }
935
+ }
936
+ return false
937
+ }
938
+
939
+ /** @param {string} sessionId session */
940
+ async #unarchive(sessionId) {
941
+ const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
942
+ for (const controller of controllers) {
943
+ if (!controller?.unarchiveSession) continue
944
+ try {
945
+ await controller.unarchiveSession(sessionId)
946
+ return true
947
+ } catch (error) {
948
+ this.log('unarchiveSession failed', error)
949
+ }
950
+ }
951
+ return false
952
+ }
953
+
954
+ /**
955
+ * Every on-disk directory that holds this session's log, whatever workspace
956
+ * folder the persistence backend chose.
957
+ *
958
+ * @param {string} sessionId session
959
+ * @returns {Promise<string[]>} directories
960
+ */
961
+ async #sessionDirs(sessionId) {
962
+ const root = path.join(this.dshHome, 'sessions')
963
+ let entries = []
964
+ try {
965
+ entries = await fs.readdir(root, { withFileTypes: true })
966
+ } catch {
967
+ return []
968
+ }
969
+ const out = []
970
+ for (const entry of entries) {
971
+ if (!entry.isDirectory()) continue
972
+ const candidate = path.join(root, entry.name, sessionId)
973
+ try {
974
+ const info = await fs.stat(candidate)
975
+ if (info.isDirectory()) out.push(candidate)
976
+ } catch {
977
+ continue
978
+ }
979
+ }
980
+ return out
981
+ }
982
+ }
983
+
984
+ /**
985
+ * @param {any} checkpoint stored record
986
+ * @returns {any} client-facing summary
987
+ */
988
+ function decorateCheckpoint(checkpoint) {
989
+ return {
990
+ id: checkpoint.id,
991
+ sessionId: checkpoint.sessionId,
992
+ seq: checkpoint.seq,
993
+ turn: checkpoint.turn,
994
+ kind: checkpoint.kind,
995
+ label: checkpoint.label,
996
+ createdAt: checkpoint.createdAt,
997
+ locked: checkpoint.locked === true,
998
+ files: checkpoint.files,
999
+ bytes: checkpoint.bytes,
1000
+ totalBytes: checkpoint.totalBytes,
1001
+ size: formatBytes(checkpoint.bytes),
1002
+ treeSize: formatBytes(checkpoint.totalBytes),
1003
+ temporary: Boolean(checkpoint.expiresAt),
1004
+ expiresAt: checkpoint.expiresAt ?? null,
1005
+ skipped: checkpoint.skipped ?? 0,
1006
+ }
1007
+ }
1008
+
1009
+ /** @param {any} config plugin config @returns {{ keep: number, maxFileBytes?: number, maxFiles?: number }} limits */
1010
+ function limitsFrom(config) {
1011
+ return {
1012
+ keep: typeof config?.keep === 'number' ? config.keep : 10,
1013
+ maxFileBytes: typeof config?.maxFileBytes === 'number' ? config.maxFileBytes : undefined,
1014
+ maxFiles: typeof config?.maxFiles === 'number' ? config.maxFiles : undefined,
1015
+ }
1016
+ }
1017
+
1018
+ /** @param {readonly any[]} events durable events @returns {string | undefined} title */
1019
+ function titleOf(events) {
1020
+ let title
1021
+ for (const event of events) {
1022
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string') title = event.data.title
1023
+ }
1024
+ return title
1025
+ }
1026
+
1027
+ /** @param {any} projections projection hints @returns {string | undefined} title */
1028
+ function titleFromProjections(projections) {
1029
+ const values = projections?.values
1030
+ if (!values || typeof values !== 'object') return undefined
1031
+ const candidate = values.title ?? values['session/title']
1032
+ return typeof candidate === 'string' ? candidate : undefined
1033
+ }
1034
+
1035
+ /**
1036
+ * Every subagent descendant of `rootId`, deepest first — a child always before
1037
+ * its parent, so the result can be used directly as a delete order.
1038
+ *
1039
+ * Only `origin === 'subagent'` establishes identity. A `parentSessionId` alone
1040
+ * does not: every rewind, message delete, and segment delete forks the
1041
+ * conversation and retires the original, so the session the user is actually in
1042
+ * carries a parent too and must never be swept into a cascade
1043
+ * (docs/pitfalls.md #48).
1044
+ *
1045
+ * The `seen` set makes the walk cycle-safe. A corrupt or hand-edited list could
1046
+ * claim A's parent is B while B's parent is A (or a session its own parent);
1047
+ * that must terminate instead of recursing forever.
1048
+ *
1049
+ * @param {readonly any[]} items SessionSummary list
1050
+ * @param {string} rootId session to start from
1051
+ * @returns {string[]} descendant session ids, deepest first, without duplicates
1052
+ */
1053
+ function subagentDescendants(items, rootId) {
1054
+ const childrenOf = new Map()
1055
+ for (const item of items) {
1056
+ if (!item || item.origin !== 'subagent' || typeof item.sessionId !== 'string') continue
1057
+ const parent = typeof item.parentSessionId === 'string' ? item.parentSessionId : undefined
1058
+ if (!parent) continue
1059
+ const siblings = childrenOf.get(parent)
1060
+ if (siblings) siblings.push(item.sessionId)
1061
+ else childrenOf.set(parent, [item.sessionId])
1062
+ }
1063
+ const out = []
1064
+ const seen = new Set([rootId])
1065
+ const visit = id => {
1066
+ for (const child of childrenOf.get(id) ?? []) {
1067
+ if (seen.has(child)) continue
1068
+ seen.add(child)
1069
+ visit(child)
1070
+ out.push(child)
1071
+ }
1072
+ }
1073
+ visit(rootId)
1074
+ return out
1075
+ }
1076
+
1077
+ /**
1078
+ * Refusal for a session that is still running; the message matches the one the
1079
+ * service has always thrown, and `sessionId` names the session that has to stop
1080
+ * when it is a subagent rather than the requested one.
1081
+ *
1082
+ * @param {string} sessionId session that is still running
1083
+ * @returns {Error} refusal carrying `code: 'session-running'`
1084
+ */
1085
+ function sessionRunningError(sessionId) {
1086
+ const error = new Error('this session is running; stop it before deleting')
1087
+ error.code = 'session-running'
1088
+ error.sessionId = sessionId
1089
+ return error
1090
+ }
1091
+
1092
+ /** @param {readonly any[]} events durable events @returns {number} last seq */
1093
+ function lastSeqOf(events) {
1094
+ let seq = 0
1095
+ for (const event of events) if (typeof event?.seq === 'number') seq = Math.max(seq, event.seq)
1096
+ return seq
1097
+ }
1098
+
1099
+ /** @param {readonly any[]} events durable events @returns {number | undefined} last turn */
1100
+ function lastTurnOf(events) {
1101
+ let turn
1102
+ for (const event of events) if (typeof event?.data?.turn === 'number') turn = event.data.turn
1103
+ return turn
1104
+ }
1105
+
1106
+ /** @param {readonly any[]} events durable events @returns {number | undefined} last finished turn */
1107
+ function lastCompletedTurn(events) {
1108
+ let turn
1109
+ for (const event of events) {
1110
+ if (event?.type === 'turn/end' && typeof event.data?.turn === 'number') turn = event.data.turn
1111
+ }
1112
+ return turn ?? lastTurnOf(events)
1113
+ }
1114
+
1115
+ /** @param {readonly any[]} events durable events @param {string} messageId target @returns {{ seq: number, turn?: number, role?: string } | undefined} match */
1116
+ function findMessage(events, messageId) {
1117
+ for (const event of events) {
1118
+ if (typeof event?.seq !== 'number') continue
1119
+ let id
1120
+ if (event.type === 'assistant/message') id = event.data?.message?.id
1121
+ else if (event.type === 'user/message') id = event.data?.id
1122
+ if (id !== messageId) continue
1123
+ const turn = typeof event.data?.turn === 'number' ? event.data.turn : turnOfSeq(events, event.seq)
1124
+ return { seq: event.seq, turn, role: event.type === 'assistant/message' ? 'assistant' : 'user' }
1125
+ }
1126
+ return undefined
1127
+ }
1128
+
1129
+ /**
1130
+ * The most recent turn that a user prompt started.
1131
+ *
1132
+ * Used when the caller cannot name a turn — the chat row may hand over a
1133
+ * location object whose shape is not a stable contract. Walking the log is
1134
+ * cheaper and safer than guessing at that shape, and a turn with no user prompt
1135
+ * (goal round, schedule) is skipped because there is nothing to edit.
1136
+ *
1137
+ * @param {readonly any[]} events durable events
1138
+ * @returns {number | undefined} the turn number, when one exists
1139
+ */
1140
+ function lastUserTurn(events) {
1141
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1142
+ const event = events[index]
1143
+ if (event?.type !== 'user/message') continue
1144
+ const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
1145
+ if (kind !== undefined && kind !== 'user') continue
1146
+ const turn = typeof event.data?.turn === 'number' ? event.data.turn : turnOfSeq(events, event.seq)
1147
+ if (typeof turn === 'number' && Number.isFinite(turn)) return turn
1148
+ }
1149
+ return undefined
1150
+ }
1151
+
1152
+ /**
1153
+ * Find the prompt that started a turn, with its text.
1154
+ *
1155
+ * Only a user-authored message counts. A turn can also be started by a goal
1156
+ * round, a schedule, or a subagent notice, and those have no prompt to resend —
1157
+ * returning undefined lets the caller say so instead of sending the wrong text.
1158
+ *
1159
+ * @param {readonly any[]} events durable events
1160
+ * @param {number} turn turn number
1161
+ * @returns {{ seq: number, text: string } | undefined} the prompt, or undefined
1162
+ */
1163
+ function promptOfTurn(events, turn) {
1164
+ let started = false
1165
+ for (const event of events) {
1166
+ if (typeof event?.seq !== 'number') continue
1167
+ if (event.type === 'turn/start') {
1168
+ started = typeof event.data?.turn === 'number' && event.data.turn === turn
1169
+ continue
1170
+ }
1171
+ // The prompt is the first user-authored message inside the turn; anything
1172
+ // else in the turn means this turn was not started by the user.
1173
+ if (!started) continue
1174
+ if (event.type === 'step/end' || event.type === 'turn/end' || event.type === 'turn/start') return undefined
1175
+ if (event.type !== 'user/message') continue
1176
+ const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
1177
+ if (kind !== undefined && kind !== 'user') continue
1178
+ return { seq: event.seq, text: contentText(event.data?.content) }
1179
+ }
1180
+ return undefined
1181
+ }
1182
+
1183
+ /**
1184
+ * @param {unknown} content message content blocks
1185
+ * @returns {string} the plain text of a message
1186
+ */
1187
+ function contentText(content) {
1188
+ if (typeof content === 'string') return content
1189
+ if (!Array.isArray(content)) return ''
1190
+ return content
1191
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
1192
+ .map(block => block.text)
1193
+ .join('\n')
1194
+ .trim()
1195
+ }
1196
+
1197
+ /**
1198
+ * @param {readonly any[]} events durable events
1199
+ * @param {number} seq exclusive bound
1200
+ * @returns {number | undefined} sequence of the closest earlier user prompt
1201
+ */
1202
+ function previousUserSeq(events, seq) {
1203
+ let found
1204
+ for (const event of events) {
1205
+ if (typeof event?.seq !== 'number' || event.seq >= seq) break
1206
+ if (event.type !== 'user/message') continue
1207
+ const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
1208
+ if (kind !== undefined && kind !== 'user') continue
1209
+ found = event.seq
1210
+ }
1211
+ return found
1212
+ }
1213
+
1214
+ /**
1215
+ * @param {readonly any[]} events durable events
1216
+ * @param {number} seq exclusive bound
1217
+ * @returns {number | undefined} sequence of the closest earlier conversation message
1218
+ */
1219
+ function previousMessageSeq(events, seq) {
1220
+ let found
1221
+ for (const event of events) {
1222
+ if (typeof event?.seq !== 'number' || event.seq >= seq) break
1223
+ if (!isConversationMessage(event)) continue
1224
+ found = event.seq
1225
+ }
1226
+ return found
1227
+ }
1228
+
1229
+ /**
1230
+ * Is this event a real turn message rather than harness plumbing?
1231
+ *
1232
+ * Compaction is append-only, so the log keeps the summarized events and then
1233
+ * adds a synthetic `user/message` carrying `source.kind === 'compact-checkpoint'`
1234
+ * plus a `surfaceOp` that replaces the summarized range. Treating that node as
1235
+ * a conversation message puts a cut point in the wrong place, so it — and any
1236
+ * other non-user source — is excluded here.
1237
+ *
1238
+ * @param {any} event durable event
1239
+ * @returns {boolean} whether the event is a user prompt or an assistant reply
1240
+ */
1241
+ function isConversationMessage(event) {
1242
+ if (event?.type === 'assistant/message') return true
1243
+ if (event?.type !== 'user/message') return false
1244
+ const source = typeof event.data?.source === 'object' && event.data.source ? event.data.source : undefined
1245
+ if (source?.kind !== undefined && source.kind !== 'user') return false
1246
+ return true
1247
+ }
1248
+
1249
+ /** @param {readonly any[]} events durable events @param {number} seq target @returns {number | undefined} turn */
1250
+ function turnOfSeq(events, seq) {
1251
+ let turn
1252
+ for (const event of events) {
1253
+ if (typeof event?.seq === 'number' && event.seq > seq) break
1254
+ if (typeof event?.data?.turn === 'number') turn = event.data.turn
1255
+ }
1256
+ return turn
1257
+ }
1258
+
1259
+ /** @param {unknown} error value @returns {string} message */
1260
+ function errorMessage(error) {
1261
+ return error instanceof Error ? error.message : String(error)
1262
+ }
1263
+
1264
+ /**
1265
+ * @param {unknown} value candidate
1266
+ * @param {number} fallback default
1267
+ * @returns {number} number
1268
+ */
1269
+ function numberOr(value, fallback) {
1270
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
1271
+ }
1272
+
1273
+ export { UNDO_TTL_MS, asString }