@anionex/dsh-turn-rewind 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
@@ -0,0 +1,584 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import { ChangeLedgerError, errorMessage } from './errors.js'
4
+ import type { ChangeLedgerEngine } from './engine.js'
5
+ import { discoverRepositoryRoot } from './git.js'
6
+
7
+ interface SessionEventLike {
8
+ readonly type: string
9
+ readonly seq: number
10
+ readonly data: Record<string, unknown>
11
+ }
12
+
13
+ interface SessionHeaderLike {
14
+ readonly cwd?: string
15
+ readonly parentSession?: string
16
+ readonly seedLength?: number
17
+ }
18
+
19
+ interface SessionLike {
20
+ readonly id: string
21
+ readonly header: SessionHeaderLike
22
+ readonly events: readonly SessionEventLike[]
23
+ }
24
+
25
+ interface AgentLike {
26
+ readonly id: string
27
+ readonly status: 'idle' | 'running'
28
+ readonly session: SessionLike
29
+ }
30
+
31
+ interface AgentsLike {
32
+ list(): AgentLike[]
33
+ }
34
+
35
+ interface SessionsLike {
36
+ get(id: string): SessionLike | undefined
37
+ }
38
+
39
+ interface SessionQueryLike {
40
+ readSession(id: string): Promise<{ readonly session: SessionHeaderLike; readonly events: readonly SessionEventLike[] }>
41
+ }
42
+
43
+ interface HttpRequestLike {
44
+ method?: string
45
+ url?: string
46
+ on(event: 'data', listener: (chunk: Uint8Array | string) => void): this
47
+ on(event: 'end', listener: () => void): this
48
+ on(event: 'error', listener: (error: unknown) => void): this
49
+ }
50
+
51
+ interface HttpResponseLike {
52
+ writeHead(status: number, headers?: Record<string, string>): unknown
53
+ end(body?: string): void
54
+ }
55
+
56
+ interface HttpServerLike {
57
+ register(route: {
58
+ kind: 'exact'
59
+ path: string
60
+ handler: (request: HttpRequestLike, response: HttpResponseLike) => void | Promise<void>
61
+ }): () => void
62
+ }
63
+
64
+ interface ApiProxyLike {
65
+ readonly sessions: {
66
+ create(request: {
67
+ readonly rpcId: string
68
+ readonly payload: { readonly cwd: string }
69
+ }): Promise<{
70
+ readonly result:
71
+ | { readonly ok: true; readonly value: { readonly sessionId: string } }
72
+ | { readonly ok: false; readonly error: { readonly message: string } }
73
+ }>
74
+ fork(request: {
75
+ readonly rpcId: string
76
+ readonly payload: { readonly sessionId: string; readonly atSeq: number }
77
+ }): Promise<{
78
+ readonly result:
79
+ | { readonly ok: true; readonly value: { readonly sessionId: string } }
80
+ | { readonly ok: false; readonly error: { readonly message: string } }
81
+ }>
82
+ }
83
+ }
84
+
85
+ declare module '@deepseek-ai/cordis' {
86
+ interface Context {
87
+ agents: AgentsLike
88
+ sessions: SessionsLike
89
+ sessionQuery: SessionQueryLike
90
+ webServer: HttpServerLike
91
+ apiProxy: ApiProxyLike
92
+ }
93
+
94
+ interface Events {
95
+ 'agent/pre-step'(
96
+ payload: {
97
+ readonly agent: AgentLike
98
+ readonly turn: number
99
+ readonly step: number
100
+ readonly signal: AbortSignal
101
+ },
102
+ next: () => Promise<unknown>,
103
+ ): Promise<unknown>
104
+ }
105
+ }
106
+
107
+ export const REWIND_HTTP_PATH = '/turn-rewind'
108
+ const BODY_LIMIT = 64 * 1024
109
+ const INITIAL_CHANGE_PREVIEW_LIMIT = 8
110
+ const MAX_CHANGE_PAGE_SIZE = 200
111
+
112
+ /** Capture each turn before its opening user message can trigger model or tool work. */
113
+ export class TurnCheckpointCoordinator {
114
+ private readonly captures = new Map<string, Promise<void>>()
115
+ private readonly pending = new Set<string>()
116
+ private readonly failures = new Map<string, string>()
117
+ private readonly workspaceTails = new Map<string, Promise<void>>()
118
+
119
+ constructor(private readonly engine: ChangeLedgerEngine) {}
120
+
121
+ /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
122
+ install(ctx: Context): void {
123
+ ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
124
+ if (step === 1) await this.capture(ctx, agent, turn, signal)
125
+ return next()
126
+ }, { prepend: true })
127
+ }
128
+
129
+ /** Current capture state for a session turn when no durable checkpoint exists yet. */
130
+ state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'missing'; readonly error?: string } {
131
+ const key = checkpointKey(sessionId, turn)
132
+ if (this.pending.has(key)) return { status: 'pending' }
133
+ const error = this.failures.get(key)
134
+ return error === undefined ? { status: 'missing' } : { status: 'failed', error }
135
+ }
136
+
137
+ private async capture(
138
+ ctx: Pick<Context, 'logger'>,
139
+ agent: AgentLike,
140
+ turn: number,
141
+ signal: AbortSignal,
142
+ ): Promise<void> {
143
+ const key = checkpointKey(agent.id, turn)
144
+ const existing = this.captures.get(key)
145
+ if (existing !== undefined) {
146
+ await existing
147
+ return
148
+ }
149
+ const cwd = agent.session.header.cwd
150
+ if (cwd === undefined) return
151
+ const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
152
+ if (start === undefined) {
153
+ this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
154
+ return
155
+ }
156
+ this.pending.add(key)
157
+ this.failures.delete(key)
158
+ const capture = (async () => {
159
+ try {
160
+ const workspace = await discoverRepositoryRoot(cwd, signal)
161
+ await this.serializeWorkspace(workspace, signal, async () => {
162
+ await this.engine.createTurnCheckpoint({
163
+ cwd: workspace,
164
+ sessionId: agent.id,
165
+ turn,
166
+ turnStartSeq: start.seq,
167
+ signal,
168
+ })
169
+ })
170
+ } catch (error) {
171
+ this.recordFailure(ctx, agent.id, turn, error)
172
+ } finally {
173
+ this.pending.delete(key)
174
+ }
175
+ })()
176
+ this.captures.set(key, capture)
177
+ await capture
178
+ }
179
+
180
+ private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
181
+ const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
182
+ const current = previous.catch(() => undefined).then(async () => {
183
+ signal.throwIfAborted()
184
+ await task()
185
+ })
186
+ this.workspaceTails.set(workspace, current)
187
+ try {
188
+ await current
189
+ } finally {
190
+ if (this.workspaceTails.get(workspace) === current) this.workspaceTails.delete(workspace)
191
+ }
192
+ }
193
+
194
+ private recordFailure(
195
+ ctx: Pick<Context, 'logger'>,
196
+ sessionId: string,
197
+ turn: number,
198
+ error: unknown,
199
+ ): void {
200
+ const message = errorMessage(error)
201
+ this.failures.set(checkpointKey(sessionId, turn), message)
202
+ ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`)
203
+ }
204
+ }
205
+
206
+ /** Register the same-origin preview/apply endpoint consumed by the browser half. */
207
+ export function installRewindHttp(
208
+ ctx: Context,
209
+ engine: ChangeLedgerEngine,
210
+ coordinator: TurnCheckpointCoordinator,
211
+ ): void {
212
+ ctx.effect(() => ctx.webServer.register({
213
+ kind: 'exact',
214
+ path: REWIND_HTTP_PATH,
215
+ handler: createRewindHttpHandler(ctx, engine, coordinator),
216
+ }), 'change-ledger.rewindHttp')
217
+ }
218
+
219
+ /** Build the exact-route handler as a testable unit. */
220
+ export function createRewindHttpHandler(
221
+ ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
222
+ engine: ChangeLedgerEngine,
223
+ coordinator: TurnCheckpointCoordinator,
224
+ ): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
225
+ return async (request, response) => {
226
+ try {
227
+ if (request.method === 'GET') {
228
+ const url = new URL(request.url ?? REWIND_HTTP_PATH, 'http://dsh.local')
229
+ const sessionId = requiredText(url.searchParams.get('sessionId'), 'sessionId')
230
+ const messageSeq = nonNegativeInteger(url.searchParams.get('messageSeq'), 'messageSeq')
231
+ const detailsOnly = url.searchParams.get('details') === '1'
232
+ const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
233
+ const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
234
+ const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
235
+ if (checkpoint === undefined) {
236
+ json(response, 200, coordinator.state(sessionId, target.turn))
237
+ return
238
+ }
239
+ const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id })
240
+ const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
241
+ const changes = inspection.changes.slice(offset, offset + limit)
242
+ const common = {
243
+ status: 'ready', sessionId, messageSeq, turn: checkpoint.turn, checkpointId: checkpoint.id,
244
+ turnStartSeq: checkpoint.turnStartSeq,
245
+ totalChanges: inspection.changes.length,
246
+ changes: changes.map(change => ({ path: change.path, kind: change.kind })),
247
+ offset,
248
+ truncated: offset + changes.length < inspection.changes.length,
249
+ headChanged: inspection.headChanged,
250
+ operationChanged: inspection.operationChanged,
251
+ checkpointHead: inspection.restorePoint.head,
252
+ checkpointBranch: inspection.restorePoint.branch,
253
+ checkpointOperation: inspection.restorePoint.operation,
254
+ currentHead: inspection.currentHead,
255
+ currentBranch: inspection.currentBranch,
256
+ currentOperation: inspection.currentOperation,
257
+ activeSessionIds,
258
+ restoreBlocked: activeSessionIds.length > 0 || inspection.operationChanged,
259
+ } as const
260
+ if (inspection.changes.length === 0) {
261
+ json(response, 200, common)
262
+ return
263
+ }
264
+ if (detailsOnly || activeSessionIds.length > 0 || inspection.operationChanged) {
265
+ json(response, 200, common)
266
+ return
267
+ }
268
+ const plan = await engine.planRestore({
269
+ cwd: checkpoint.cwd,
270
+ restorePointId: checkpoint.id,
271
+ sessionId,
272
+ allowHeadChange: inspection.headChanged,
273
+ expectedCurrentTreeHash: inspection.currentTreeHash,
274
+ expectedRepository: inspection.currentRepository,
275
+ })
276
+ json(response, 200, { ...common, planId: plan.id, confirmation: plan.confirmation })
277
+ return
278
+ }
279
+ if (request.method === 'POST') {
280
+ const body = objectBody(await readBody(request))
281
+ const mode = body.mode
282
+ if (mode !== 'code' && mode !== 'both') {
283
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"')
284
+ }
285
+ const sessionId = requiredText(body.sessionId, 'sessionId')
286
+ const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
287
+ const checkpointId = requiredText(body.checkpointId, 'checkpointId')
288
+ const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
289
+ const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
290
+ if (activeSessionIds.length > 0) {
291
+ throw new ChangeLedgerError(
292
+ 'WORKSPACE_IN_USE',
293
+ `project files are also used by active sessions: ${activeSessionIds.slice(0, 5).join(', ')}`,
294
+ )
295
+ }
296
+ const planId = optionalText(body.planId, 'planId')
297
+ const confirmation = optionalText(body.confirmation, 'confirmation')
298
+ if (planId === undefined || confirmation === undefined) {
299
+ throw new ChangeLedgerError('NO_CHANGES', 'the selected turn has no project files to restore')
300
+ }
301
+ const restoreResult = await engine.applyRestore({ planId, confirmation, sessionId })
302
+ if (mode === 'code') {
303
+ json(response, 200, { status: 'completed', mode, ...restoreResult })
304
+ return
305
+ }
306
+
307
+ try {
308
+ const fork = await createConversationRestart(ctx, sessionId, checkpoint)
309
+ json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId, ...restoreResult })
310
+ } catch (forkError) {
311
+ if (restoreResult === undefined) throw forkError
312
+ try {
313
+ const rollbackPlan = await engine.planRestore({
314
+ cwd: checkpoint.cwd,
315
+ restorePointId: restoreResult.rescuePointId,
316
+ sessionId,
317
+ })
318
+ await engine.applyRestore({
319
+ planId: rollbackPlan.id,
320
+ confirmation: rollbackPlan.confirmation,
321
+ sessionId,
322
+ })
323
+ } catch (rollbackError) {
324
+ throw new AggregateError([forkError, rollbackError], 'conversation fork failed and code compensation also failed')
325
+ }
326
+ throw new ChangeLedgerError(
327
+ 'RESTORE_FAILED_ROLLED_BACK',
328
+ `conversation fork failed; code was recovered from ${restoreResult.rescuePointId}: ${errorMessage(forkError)}`,
329
+ { cause: forkError },
330
+ )
331
+ }
332
+ return
333
+ }
334
+ json(response, 405, { error: 'method not allowed' })
335
+ } catch (error) {
336
+ const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
337
+ json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'REWIND_FAILED' })
338
+ }
339
+ }
340
+ }
341
+
342
+ async function readSession(
343
+ ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
344
+ sessionId: string,
345
+ ): Promise<SessionLike> {
346
+ const live = ctx.sessions.get(sessionId)
347
+ if (live !== undefined) return live
348
+ const stored = await ctx.sessionQuery.readSession(sessionId)
349
+ return { id: sessionId, header: stored.session, events: stored.events }
350
+ }
351
+
352
+ interface MessageTarget {
353
+ readonly messageSeq: number
354
+ readonly turn: number
355
+ readonly turnStartSeq: number
356
+ readonly previousTurnEndSeq?: number
357
+ readonly cwd: string
358
+ }
359
+
360
+ interface MessageCheckpoint extends MessageTarget {
361
+ readonly id: string
362
+ }
363
+
364
+ async function resolveMessageCheckpoint(
365
+ ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
366
+ engine: ChangeLedgerEngine,
367
+ sessionId: string,
368
+ messageSeq: number,
369
+ ): Promise<{ readonly target: MessageTarget; readonly checkpoint?: MessageCheckpoint }> {
370
+ let current = await readSession(ctx, sessionId)
371
+ const target = messageTarget(current, messageSeq)
372
+ const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
373
+ if (direct !== undefined) {
374
+ if (direct.turnStartSeq !== target.turnStartSeq) {
375
+ throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
376
+ }
377
+ return { target, checkpoint: { ...target, id: direct.id } }
378
+ }
379
+
380
+ const seen = new Set<string>([sessionId])
381
+ while (true) {
382
+ const parentId = current.header.parentSession
383
+ const seedLength = current.header.seedLength
384
+ if ((parentId === undefined) !== (seedLength === undefined)) {
385
+ throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata')
386
+ }
387
+ if (parentId === undefined || seedLength === undefined
388
+ || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
389
+ return { target }
390
+ }
391
+ if (seen.has(parentId)) {
392
+ throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
393
+ }
394
+ seen.add(parentId)
395
+ try {
396
+ current = await readSession(ctx, parentId)
397
+ } catch (error) {
398
+ throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
399
+ }
400
+ const parentTarget = messageTarget(current, messageSeq)
401
+ if (parentTarget.turn !== target.turn
402
+ || parentTarget.turnStartSeq !== target.turnStartSeq
403
+ || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
404
+ throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage no longer matches the inherited message boundary')
405
+ }
406
+ const inherited = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId: parentId, turn: target.turn })
407
+ if (inherited === undefined) continue
408
+ if (inherited.turnStartSeq !== target.turnStartSeq) {
409
+ throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
410
+ }
411
+ return { target, checkpoint: { ...target, id: inherited.id } }
412
+ }
413
+ }
414
+
415
+ async function checkpointForRequest(
416
+ ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
417
+ engine: ChangeLedgerEngine,
418
+ sessionId: string,
419
+ messageSeq: number,
420
+ requestedId: string,
421
+ ): Promise<MessageCheckpoint> {
422
+ const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
423
+ if (checkpoint === undefined) {
424
+ throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
425
+ }
426
+ if (requestedId !== checkpoint.id) {
427
+ throw new ChangeLedgerError('PLAN_STALE', 'the selected message checkpoint changed; reopen the rewind dialog')
428
+ }
429
+ return checkpoint
430
+ }
431
+
432
+ async function createConversationRestart(
433
+ ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
434
+ sourceId: string,
435
+ checkpoint: MessageCheckpoint,
436
+ ): Promise<{ readonly sessionId: string }> {
437
+ const source = await readSession(ctx, sourceId)
438
+ const current = messageTarget(source, checkpoint.messageSeq)
439
+ if (current.turn !== checkpoint.turn
440
+ || current.turnStartSeq !== checkpoint.turnStartSeq
441
+ || current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
442
+ throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
443
+ }
444
+ const response = checkpoint.previousTurnEndSeq === undefined
445
+ ? await ctx.apiProxy.sessions.create({
446
+ rpcId: randomUUID(),
447
+ payload: { cwd: checkpoint.cwd },
448
+ })
449
+ : await ctx.apiProxy.sessions.fork({
450
+ rpcId: randomUUID(),
451
+ payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
452
+ })
453
+ if (!response.result.ok) {
454
+ throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
455
+ }
456
+ return { sessionId: requiredText(response.result.value.sessionId, 'fork sessionId') }
457
+ }
458
+
459
+ function messageTarget(session: SessionLike, messageSeq: number): MessageTarget {
460
+ const cwd = session.header.cwd
461
+ if (cwd === undefined) throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`)
462
+ const message = session.events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event))
463
+ if (message === undefined) {
464
+ throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`)
465
+ }
466
+ const start = session.events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq)
467
+ const turn = start?.data.turn
468
+ if (start === undefined || !Number.isSafeInteger(turn) || (turn as number) < 0) {
469
+ throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start')
470
+ }
471
+ const openingMessage = session.events.find(event => (
472
+ event.type === 'user/message'
473
+ && event.seq > start.seq
474
+ && event.seq <= messageSeq
475
+ && isDirectUserMessage(event)
476
+ ))
477
+ if (openingMessage?.seq !== messageSeq) {
478
+ throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn')
479
+ }
480
+ const interveningEnd = session.events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq)
481
+ if (interveningEnd !== undefined) {
482
+ throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn')
483
+ }
484
+ const previousEnd = session.events.findLast(event => event.type === 'turn/end' && event.seq < start.seq)
485
+ return {
486
+ messageSeq,
487
+ turn: turn as number,
488
+ turnStartSeq: start.seq,
489
+ ...(previousEnd === undefined ? {} : { previousTurnEndSeq: previousEnd.seq }),
490
+ cwd,
491
+ }
492
+ }
493
+
494
+ function isDirectUserMessage(event: SessionEventLike): boolean {
495
+ const source = event.data.source
496
+ return source !== null && typeof source === 'object' && !Array.isArray(source)
497
+ && (source as Record<string, unknown>).kind === 'user'
498
+ }
499
+
500
+ function checkpointKey(sessionId: string, turn: number): string {
501
+ return `${sessionId}\0${String(turn)}`
502
+ }
503
+
504
+ function requiredText(value: unknown, name: string): string {
505
+ if (typeof value !== 'string' || value === '') throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-empty string`)
506
+ return value
507
+ }
508
+
509
+ function optionalText(value: unknown, name: string): string | undefined {
510
+ return value === undefined ? undefined : requiredText(value, name)
511
+ }
512
+
513
+ function nonNegativeInteger(value: unknown, name: string): number {
514
+ const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
515
+ if (!Number.isSafeInteger(parsed) || (parsed as number) < 0) {
516
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-negative safe integer`)
517
+ }
518
+ return parsed as number
519
+ }
520
+
521
+ function pageSize(value: unknown, fallback: number): number {
522
+ if (value === null || value === undefined) return fallback
523
+ const parsed = nonNegativeInteger(value, 'limit')
524
+ if (parsed < 1 || parsed > MAX_CHANGE_PAGE_SIZE) {
525
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', `limit must be between 1 and ${String(MAX_CHANGE_PAGE_SIZE)}`)
526
+ }
527
+ return parsed
528
+ }
529
+
530
+ async function sharedWorkspaceSessions(
531
+ agents: AgentsLike | undefined,
532
+ cwd: string,
533
+ ): Promise<readonly string[]> {
534
+ const listed = agents?.list() ?? []
535
+ if (listed.length === 0) return []
536
+ const root = await discoverRepositoryRoot(cwd)
537
+ const shared: string[] = []
538
+ for (const agent of listed) {
539
+ if (agent.status !== 'running') continue
540
+ const session = agent.session
541
+ if (session.header.cwd === undefined) continue
542
+ try {
543
+ if (await discoverRepositoryRoot(session.header.cwd) === root) shared.push(session.id)
544
+ } catch {
545
+ // A live Session whose cwd is not a valid Git worktree cannot share this worktree.
546
+ }
547
+ }
548
+ return shared.sort()
549
+ }
550
+
551
+ function objectBody(value: unknown): Record<string, unknown> {
552
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
553
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be an object')
554
+ }
555
+ return value as Record<string, unknown>
556
+ }
557
+
558
+ async function readBody(request: HttpRequestLike): Promise<unknown> {
559
+ const chunks: Buffer[] = []
560
+ let size = 0
561
+ await new Promise<void>((resolve, reject) => {
562
+ request.on('data', (chunk) => {
563
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
564
+ size += bytes.length
565
+ if (size > BODY_LIMIT) {
566
+ reject(new ChangeLedgerError('INVALID_ARGUMENTS', 'request body is too large'))
567
+ return
568
+ }
569
+ chunks.push(bytes)
570
+ })
571
+ request.on('end', resolve)
572
+ request.on('error', reject)
573
+ })
574
+ try {
575
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
576
+ } catch (error) {
577
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be valid JSON', { cause: error })
578
+ }
579
+ }
580
+
581
+ function json(response: HttpResponseLike, status: number, value: unknown): void {
582
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
583
+ response.end(`${JSON.stringify(value)}\n`)
584
+ }