@anionex/dsh-turn-rewind 0.2.0 → 0.2.2

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.
@@ -2,11 +2,19 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createDeadline } from './deadline.js';
3
3
  import { ChangeLedgerError, errorMessage } from './errors.js';
4
4
  import { discoverRepositoryRoot } from './git.js';
5
+ function getSessionEvents(session) {
6
+ if (!session)
7
+ return [];
8
+ if (typeof session.snapshotEvents === 'function') {
9
+ return session.snapshotEvents();
10
+ }
11
+ return session.events ?? [];
12
+ }
5
13
  export const REWIND_HTTP_PATH = '/turn-rewind';
6
14
  const BODY_LIMIT = 64 * 1024;
7
15
  const INITIAL_CHANGE_PREVIEW_LIMIT = 8;
8
16
  const MAX_CHANGE_PAGE_SIZE = 200;
9
- /** Capture each turn before its opening user message can trigger model or tool work. */
17
+ /** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
10
18
  export class TurnCheckpointCoordinator {
11
19
  engine;
12
20
  captures = new Map();
@@ -17,11 +25,17 @@ export class TurnCheckpointCoordinator {
17
25
  constructor(engine) {
18
26
  this.engine = engine;
19
27
  }
20
- /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
28
+ /** Keep sidecar checkpoint work out of the Agent response waterfall. */
21
29
  install(ctx) {
22
30
  ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
23
31
  if (step === 1)
24
- await this.capture(ctx, agent, turn, signal);
32
+ this.startCapture(ctx, agent, turn, signal);
33
+ return next();
34
+ }, { prepend: true });
35
+ ctx.on('tools/execute', async (exec, next) => {
36
+ if (exec.agent !== undefined && exec.parent === undefined) {
37
+ await this.waitForOpenTurn(exec.agent, exec.signal);
38
+ }
25
39
  return next();
26
40
  }, { prepend: true });
27
41
  }
@@ -36,12 +50,22 @@ export class TurnCheckpointCoordinator {
36
50
  const error = this.failures.get(key);
37
51
  return error === undefined ? { status: 'missing' } : { status: 'failed', error };
38
52
  }
53
+ startCapture(ctx, agent, turn, signal) {
54
+ const key = checkpointKey(agent.id, turn);
55
+ if (this.captures.has(key))
56
+ return;
57
+ const capture = this.capture(ctx, agent, turn, signal).catch(async (error) => {
58
+ await this.recordFailure(ctx, agent.id, turn, error);
59
+ });
60
+ this.captures.set(key, capture);
61
+ }
39
62
  async capture(ctx, agent, turn, signal) {
40
63
  const key = checkpointKey(agent.id, turn);
41
64
  const cwd = agent.session.header.cwd;
42
65
  if (cwd === undefined)
43
66
  return;
44
- const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
67
+ const events = getSessionEvents(agent.session);
68
+ const start = events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
45
69
  if (start === undefined) {
46
70
  await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
47
71
  return;
@@ -53,11 +77,6 @@ export class TurnCheckpointCoordinator {
53
77
  const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs));
54
78
  const captureSignal = AbortSignal.any([signal, captureDeadline.signal]);
55
79
  try {
56
- const existing = this.captures.get(key);
57
- if (existing !== undefined) {
58
- await waitWithSignal(existing, captureSignal).catch(() => undefined);
59
- return;
60
- }
61
80
  this.pending.add(key);
62
81
  this.failures.delete(key);
63
82
  this.skips.delete(key);
@@ -86,7 +105,6 @@ export class TurnCheckpointCoordinator {
86
105
  this.pending.delete(key);
87
106
  }
88
107
  })();
89
- this.captures.set(key, capture);
90
108
  try {
91
109
  await waitWithSignal(capture, outcomeSignal);
92
110
  }
@@ -107,6 +125,30 @@ export class TurnCheckpointCoordinator {
107
125
  outcomeDeadline.cancel();
108
126
  }
109
127
  }
128
+ /** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
129
+ async waitForOpenTurn(agent, signal) {
130
+ if (signal.aborted)
131
+ return;
132
+ const events = getSessionEvents(agent.session);
133
+ const boundary = events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
134
+ if (boundary?.type !== 'turn/start')
135
+ return;
136
+ const turn = boundary.data.turn;
137
+ if (!Number.isSafeInteger(turn) || turn < 0)
138
+ return;
139
+ const capture = this.captures.get(checkpointKey(agent.id, turn));
140
+ if (capture === undefined)
141
+ return;
142
+ try {
143
+ await waitWithSignal(capture, signal);
144
+ }
145
+ catch (error) {
146
+ // Delegate caller cancellation so the DSH tool runtime preserves its
147
+ // canonical ABORTED_BEFORE_DISPATCH result instead of a wrapper error.
148
+ if (!signal.aborted)
149
+ throw error;
150
+ }
151
+ }
110
152
  async serializeWorkspace(workspace, signal, task) {
111
153
  const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
112
154
  const current = (async () => {
@@ -182,95 +224,6 @@ export function installRewindHttp(ctx, engine, coordinator) {
182
224
  handler: createRewindHttpHandler(ctx, engine, coordinator),
183
225
  }), 'change-ledger.rewindHttp');
184
226
  }
185
- export const MANAGE_HTTP_PATH = '/turn-rewind/manage';
186
- /** Register the same-origin storage-management endpoint consumed by the settings card. */
187
- export function installManageHttp(ctx, engine) {
188
- ctx.effect(() => ctx.webServer.register({
189
- kind: 'exact',
190
- path: MANAGE_HTTP_PATH,
191
- handler: createManageHttpHandler(engine),
192
- }), 'change-ledger.manageHttp');
193
- }
194
- /** Build the storage-management route as a testable unit. */
195
- export function createManageHttpHandler(engine) {
196
- return async (request, response) => {
197
- try {
198
- if (request.method === 'GET') {
199
- const workspaces = await engine.listWorkspaces();
200
- json(response, 200, {
201
- storageDir: engine.config.storageDir,
202
- totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
203
- workspaces: workspaces.map((workspace) => ({
204
- workspace: workspace.workspace,
205
- totalBytes: workspace.totalBytes,
206
- recoveryCount: workspace.recoveryCount,
207
- restorePoints: workspace.restorePoints.map((point) => ({
208
- id: point.id,
209
- kind: point.kind,
210
- format: point.format,
211
- createdAt: point.createdAt,
212
- totalBytes: point.totalBytes,
213
- fileCount: point.fileCount,
214
- ...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
215
- ...(point.turn === undefined ? {} : { turn: point.turn }),
216
- ...(point.label === undefined ? {} : { label: point.label }),
217
- })),
218
- })),
219
- });
220
- return;
221
- }
222
- if (request.method === 'POST') {
223
- const body = objectBody(await readBody(request));
224
- const action = requiredText(body.action, 'action');
225
- if (action === 'delete') {
226
- const workspace = requiredText(body.workspace, 'workspace');
227
- const restorePointId = requiredText(body.restorePointId, 'restorePointId');
228
- const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] });
229
- if (report.deletedRestorePoints === 0) {
230
- throw new ChangeLedgerError(report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND', report.retainedRestorePoints > 0
231
- ? 'restore point is required by an incomplete recovery journal'
232
- : `restore point ${restorePointId} does not exist`);
233
- }
234
- json(response, 200, { status: 'completed', action, ...report });
235
- return;
236
- }
237
- if (action === 'clear-workspace') {
238
- const workspace = requiredText(body.workspace, 'workspace');
239
- json(response, 200, { status: 'completed', action, ...await engine.purgeWorkspace({ workspace }) });
240
- return;
241
- }
242
- if (action === 'clear-all') {
243
- const reports = [];
244
- const failures = [];
245
- for (const overview of await engine.listWorkspaces()) {
246
- try {
247
- reports.push(await engine.purgeWorkspace({ workspace: overview.workspace }));
248
- }
249
- catch (error) {
250
- failures.push({ workspace: overview.workspace, error: errorMessage(error) });
251
- }
252
- }
253
- json(response, 200, {
254
- status: failures.length > 0 ? 'partial' : 'completed',
255
- action,
256
- reports,
257
- ...(failures.length > 0 ? { failures } : {}),
258
- });
259
- return;
260
- }
261
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'action must be "delete", "clear-workspace", or "clear-all"');
262
- }
263
- json(response, 405, { error: 'method not allowed' });
264
- }
265
- catch (error) {
266
- const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409;
267
- json(response, status, {
268
- error: errorMessage(error),
269
- code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED',
270
- });
271
- }
272
- };
273
- }
274
227
  /** Build the exact-route handler as a testable unit. */
275
228
  export function createRewindHttpHandler(ctx, engine, coordinator) {
276
229
  return async (request, response) => {
@@ -282,17 +235,7 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
282
235
  const detailsOnly = url.searchParams.get('details') === '1';
283
236
  const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset');
284
237
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
285
- const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
286
- let checkpoint;
287
- try {
288
- checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target);
289
- }
290
- catch (error) {
291
- // File-checkpoint discovery is allowed to fail independently: the dialog
292
- // must still expose message-only rewind for a valid Session boundary.
293
- json(response, 200, { status: 'failed', error: errorMessage(error) });
294
- return;
295
- }
238
+ const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
296
239
  if (checkpoint === undefined) {
297
240
  const durableSkip = await engine.findTurnCheckpointSkip({
298
241
  cwd: target.cwd,
@@ -348,20 +291,11 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
348
291
  if (request.method === 'POST') {
349
292
  const body = objectBody(await readBody(request));
350
293
  const mode = body.mode;
351
- if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
352
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"');
294
+ if (mode !== 'code' && mode !== 'both') {
295
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"');
353
296
  }
354
297
  const sessionId = requiredText(body.sessionId, 'sessionId');
355
298
  const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq');
356
- if (mode === 'messages') {
357
- // Message-only rewind never mutates project files, so no checkpoint, no
358
- // restore plan, and no shared-workspace gate apply; the conversation
359
- // restart revalidates the message boundary itself.
360
- const { target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
361
- const fork = await createConversationRestart(ctx, sessionId, target);
362
- json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId });
363
- return;
364
- }
365
299
  const checkpointId = requiredText(body.checkpointId, 'checkpointId');
366
300
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
367
301
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
@@ -419,18 +353,15 @@ async function readSession(ctx, sessionId) {
419
353
  const stored = await ctx.sessionQuery.readSession(sessionId);
420
354
  return { id: sessionId, header: stored.session, events: stored.events };
421
355
  }
422
- async function resolveMessageTarget(ctx, sessionId, messageSeq) {
423
- const session = await readSession(ctx, sessionId);
424
- return { session, target: messageTarget(session, messageSeq) };
425
- }
426
- async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target) {
427
- let current = source;
356
+ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
357
+ let current = await readSession(ctx, sessionId);
358
+ const target = messageTarget(current, messageSeq);
428
359
  const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn });
429
360
  if (direct !== undefined) {
430
361
  if (direct.turnStartSeq !== target.turnStartSeq) {
431
362
  throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start');
432
363
  }
433
- return { ...target, id: direct.id };
364
+ return { target, checkpoint: { ...target, id: direct.id } };
434
365
  }
435
366
  const seen = new Set([sessionId]);
436
367
  while (true) {
@@ -441,7 +372,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
441
372
  }
442
373
  if (parentId === undefined || seedLength === undefined
443
374
  || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
444
- return undefined;
375
+ return { target };
445
376
  }
446
377
  if (seen.has(parentId)) {
447
378
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle');
@@ -453,7 +384,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
453
384
  catch (error) {
454
385
  throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error });
455
386
  }
456
- const parentTarget = messageTarget(current, target.messageSeq);
387
+ const parentTarget = messageTarget(current, messageSeq);
457
388
  if (parentTarget.turn !== target.turn
458
389
  || parentTarget.turnStartSeq !== target.turnStartSeq
459
390
  || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
@@ -465,12 +396,11 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
465
396
  if (inherited.turnStartSeq !== target.turnStartSeq) {
466
397
  throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary');
467
398
  }
468
- return { ...target, id: inherited.id };
399
+ return { target, checkpoint: { ...target, id: inherited.id } };
469
400
  }
470
401
  }
471
402
  async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
472
- const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
473
- const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target);
403
+ const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
474
404
  if (checkpoint === undefined) {
475
405
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`);
476
406
  }
@@ -479,22 +409,33 @@ async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requeste
479
409
  }
480
410
  return checkpoint;
481
411
  }
482
- async function createConversationRestart(ctx, sourceId, target) {
412
+ async function createConversationRestart(ctx, sourceId, checkpoint) {
483
413
  const source = await readSession(ctx, sourceId);
484
- const current = messageTarget(source, target.messageSeq);
485
- if (current.turn !== target.turn
486
- || current.turnStartSeq !== target.turnStartSeq
487
- || current.previousTurnEndSeq !== target.previousTurnEndSeq) {
414
+ const current = messageTarget(source, checkpoint.messageSeq);
415
+ if (current.turn !== checkpoint.turn
416
+ || current.turnStartSeq !== checkpoint.turnStartSeq
417
+ || current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
488
418
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
489
419
  }
490
- const response = target.previousTurnEndSeq === undefined
491
- ? await ctx.apiProxy.sessions.create({
420
+ const controller = ctx.sessionController;
421
+ if (controller !== undefined) {
422
+ const created = checkpoint.previousTurnEndSeq === undefined
423
+ ? await controller.create({ cwd: checkpoint.cwd })
424
+ : await controller.fork({ sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq });
425
+ return { sessionId: requiredText(created.sessionId, 'sessionController sessionId') };
426
+ }
427
+ const apiProxy = ctx.apiProxy;
428
+ if (apiProxy === undefined) {
429
+ throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', 'this DSH host exposes neither sessionController nor apiProxy, so a new conversation cannot be created');
430
+ }
431
+ const response = checkpoint.previousTurnEndSeq === undefined
432
+ ? await apiProxy.sessions.create({
492
433
  rpcId: randomUUID(),
493
- payload: { cwd: target.cwd },
434
+ payload: { cwd: checkpoint.cwd },
494
435
  })
495
- : await ctx.apiProxy.sessions.fork({
436
+ : await apiProxy.sessions.fork({
496
437
  rpcId: randomUUID(),
497
- payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
438
+ payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
498
439
  });
499
440
  if (!response.result.ok) {
500
441
  throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message);
@@ -505,27 +446,28 @@ function messageTarget(session, messageSeq) {
505
446
  const cwd = session.header.cwd;
506
447
  if (cwd === undefined)
507
448
  throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`);
508
- const message = session.events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event));
449
+ const events = getSessionEvents(session);
450
+ const message = events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event));
509
451
  if (message === undefined) {
510
452
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`);
511
453
  }
512
- const start = session.events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq);
454
+ const start = events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq);
513
455
  const turn = start?.data.turn;
514
456
  if (start === undefined || !Number.isSafeInteger(turn) || turn < 0) {
515
457
  throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start');
516
458
  }
517
- const openingMessage = session.events.find(event => (event.type === 'user/message'
459
+ const openingMessage = events.find(event => (event.type === 'user/message'
518
460
  && event.seq > start.seq
519
461
  && event.seq <= messageSeq
520
462
  && isDirectUserMessage(event)));
521
463
  if (openingMessage?.seq !== messageSeq) {
522
464
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn');
523
465
  }
524
- const interveningEnd = session.events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq);
466
+ const interveningEnd = events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq);
525
467
  if (interveningEnd !== undefined) {
526
468
  throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn');
527
469
  }
528
- const previousEnd = session.events.findLast(event => event.type === 'turn/end' && event.seq < start.seq);
470
+ const previousEnd = events.findLast(event => event.type === 'turn/end' && event.seq < start.seq);
529
471
  return {
530
472
  messageSeq,
531
473
  turn: turn,
@@ -621,3 +563,66 @@ function json(response, status, value) {
621
563
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
622
564
  response.end(`${JSON.stringify(value)}\n`);
623
565
  }
566
+ export const MANAGE_HTTP_PATH = '/turn-rewind/manage';
567
+ /** Register the same-origin storage-management endpoint consumed by the settings card. */
568
+ export function installManageHttp(ctx, engine) {
569
+ ctx.effect(() => ctx.webServer.register({
570
+ kind: 'exact',
571
+ path: MANAGE_HTTP_PATH,
572
+ handler: createManageHttpHandler(engine),
573
+ }), 'change-ledger.manageHttp');
574
+ }
575
+ /** Build the storage-management route as a testable unit. */
576
+ export function createManageHttpHandler(engine) {
577
+ return async (request, response) => {
578
+ try {
579
+ if (request.method === 'GET') {
580
+ const workspaces = await engine.listWorkspaces();
581
+ json(response, 200, {
582
+ storageDir: engine.config.storageDir,
583
+ totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
584
+ workspaces: workspaces.map((workspace) => ({
585
+ workspace: workspace.workspace,
586
+ totalBytes: workspace.totalBytes,
587
+ recoveryCount: workspace.recoveryCount,
588
+ restorePoints: workspace.restorePoints.map((point) => ({
589
+ id: point.id,
590
+ kind: point.kind,
591
+ format: point.format,
592
+ createdAt: point.createdAt,
593
+ totalBytes: point.totalBytes,
594
+ fileCount: point.fileCount,
595
+ ...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
596
+ ...(point.turn === undefined ? {} : { turn: point.turn }),
597
+ ...(point.label === undefined ? {} : { label: point.label }),
598
+ })),
599
+ })),
600
+ });
601
+ return;
602
+ }
603
+ if (request.method === 'POST') {
604
+ const body = objectBody(await readBody(request));
605
+ const action = requiredText(body.action, 'action');
606
+ if (action === 'delete') {
607
+ const workspace = requiredText(body.workspace, 'workspace');
608
+ const restorePointId = requiredText(body.restorePointId, 'restorePointId');
609
+ const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] });
610
+ if (report.deletedRestorePoints === 0) {
611
+ throw new ChangeLedgerError(report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND', report.retainedRestorePoints > 0
612
+ ? 'restore point is required by an incomplete recovery journal'
613
+ : `restore point ${restorePointId} does not exist`);
614
+ }
615
+ json(response, 200, { status: 'completed', action, ...report });
616
+ return;
617
+ }
618
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', `unsupported manage action ${action}`);
619
+ }
620
+ response.writeHead(405, { allow: 'GET, POST' });
621
+ response.end();
622
+ }
623
+ catch (error) {
624
+ const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409;
625
+ json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED' });
626
+ }
627
+ };
628
+ }
@@ -12,14 +12,29 @@ interface ConversationChatNodeLike {
12
12
  readonly kind: string;
13
13
  readonly data: ConversationNodeLike;
14
14
  }
15
- interface ConversationSnapshotLike {
16
- readonly nodes: readonly ConversationNodeLike[];
17
- readonly chat?: {
18
- readonly nodes: {
19
- values(): readonly ConversationChatNodeLike[];
20
- };
21
- };
15
+ /**
16
+ * Chat node store exposed by the Chat target. DSH 0.1.2+ publishes a mutable
17
+ * keyed store; older clients published a Map with the same `get` contract.
18
+ */
19
+ interface ChatNodeStoreLike {
20
+ get(key: string): RewindNodeLike | undefined;
21
+ values?(): Iterable<RewindNodeLike>;
22
+ }
23
+ /** Chat projection shared by `session.chat` (0.1.1) and the `useChat` hook (0.1.2+). */
24
+ interface ChatSnapshotLike {
25
+ readonly order?: readonly string[];
26
+ readonly nodes?: ChatNodeStoreLike | readonly RewindNodeLike[];
27
+ }
28
+ /**
29
+ * Snapshot seen by a session-scoped slot entry. 0.1.1 exposes the chat
30
+ * projection as `snapshot.chat`; 0.1.2+ moved it to the separate `useChat`
31
+ * hook, so the same snapshot is the chat projection itself.
32
+ */
33
+ interface ConversationSnapshotLike extends ChatSnapshotLike {
34
+ readonly chat?: ChatSnapshotLike;
22
35
  }
36
+ /** Selector hook shape shared by `useSession` and `useChat`. */
37
+ type SnapshotSelectorHook = <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T;
23
38
  type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike;
24
39
  interface RewindMatch {
25
40
  readonly messageSeq: number;
@@ -33,7 +48,8 @@ interface RewindMessageActionProps {
33
48
  interface RewindPortalBridgeProps {
34
49
  readonly sessionId: string;
35
50
  readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>;
36
- readonly useSession: <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T;
51
+ readonly useSession: SnapshotSelectorHook;
52
+ readonly useChat?: SnapshotSelectorHook;
37
53
  }
38
54
  interface SlotsLike {
39
55
  inject(name: string, install: () => unknown): void;
@@ -127,8 +143,20 @@ export declare function selectRewindMessage(node: ConversationNodeLike): RewindM
127
143
  /** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
128
144
  export declare const inject: string[];
129
145
  export declare function apply(ctx: ClientContextLike): void;
146
+ /**
147
+ * Resolve the ordered chat node list from one chat projection.
148
+ *
149
+ * Both inputs keep a stable identity across renders: `order` is republished only
150
+ * when the node set changes and the store is a mutable handle, so the caller can
151
+ * memoize the list instead of allocating a new array on every render (a fresh
152
+ * array per render makes `useSyncExternalStore` loop forever).
153
+ * @param order - ordered node keys, or null when the projection is absent.
154
+ * @param store - keyed node store, a legacy node array, or null.
155
+ * @returns the chat nodes in render order.
156
+ */
157
+ export declare function collectChatNodes(order: readonly string[] | null, store: ChatNodeStoreLike | readonly RewindNodeLike[] | null): readonly RewindNodeLike[];
130
158
  /** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
131
- export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode;
159
+ export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession, useChat }: RewindPortalBridgeProps): ReactNode;
132
160
  /** User-message action and its review-first file/conversation restore dialog. */
133
161
  export declare function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode;
134
162
  interface TurnRewindSettingsCardProps {
@@ -143,6 +171,26 @@ export declare function selectRewindMessageTarget(value: RewindNodeLike): {
143
171
  readonly matched: RewindMatch;
144
172
  readonly rowKey: string;
145
173
  } | null;
174
+ /**
175
+ * Read one rewind response body without ever leaking a raw parse error.
176
+ *
177
+ * A missing route, a restarted Host, or a proxy answering before the plugin
178
+ * loads all produce a body that is not the plugin's JSON envelope; those must
179
+ * surface as an explained failure instead of `Failed to execute 'json' …`.
180
+ * @param response - fetch response from the rewind endpoint.
181
+ * @returns the decoded JSON body.
182
+ */
183
+ export declare function responseJson(response: Response): Promise<unknown>;
146
184
  /** Describe the user-visible result of restoring one changed file. */
147
185
  export declare function fileRecoveryLabel(kind: ChangeKind): string;
186
+ /**
187
+ * Explain one recorded checkpoint failure in user terms.
188
+ *
189
+ * The Host records the raw `[CODE] diagnostic` line; a non-Git project
190
+ * directory is the common case and deserves a plain sentence instead of a
191
+ * `git rev-parse` transcript.
192
+ * @param message - recorded checkpoint failure message.
193
+ * @returns one user-facing sentence.
194
+ */
195
+ export declare function explainCheckpointFailure(message: string): string;
148
196
  export {};
@@ -13,13 +13,19 @@ interface SessionHeaderLike {
13
13
  interface SessionLike {
14
14
  readonly id: string;
15
15
  readonly header: SessionHeaderLike;
16
- readonly events: readonly SessionEventLike[];
16
+ readonly events?: readonly SessionEventLike[];
17
+ snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEventLike[];
17
18
  }
18
19
  interface AgentLike {
19
20
  readonly id: string;
20
21
  readonly status: 'idle' | 'running';
21
22
  readonly session: SessionLike;
22
23
  }
24
+ interface ToolExecutionLike {
25
+ readonly agent?: AgentLike;
26
+ readonly parent?: symbol;
27
+ readonly signal: AbortSignal;
28
+ }
23
29
  interface AgentsLike {
24
30
  list(): AgentLike[];
25
31
  }
@@ -91,6 +97,31 @@ interface ApiProxyLike {
91
97
  }>;
92
98
  };
93
99
  }
100
+ /**
101
+ * Conversation create/fork capability of the Session controller service.
102
+ *
103
+ * DSH 0.1.2-alpha replaced the `apiProxy` RPC envelope with this plain service,
104
+ * so carriers without `apiProxy` (DSH Desktop 2.x) reach the same capability here.
105
+ */
106
+ interface SessionControllerLike {
107
+ create(request: {
108
+ readonly cwd?: string;
109
+ readonly workspaceId?: string;
110
+ }): Promise<{
111
+ readonly sessionId: string;
112
+ }>;
113
+ fork(request: {
114
+ readonly sessionId: string;
115
+ readonly atSeq?: number;
116
+ }): Promise<{
117
+ readonly sessionId: string;
118
+ }>;
119
+ }
120
+ /** Services a conversation restart can be built on, in either carrier shape. */
121
+ type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery'> & {
122
+ readonly apiProxy?: ApiProxyLike;
123
+ readonly sessionController?: SessionControllerLike;
124
+ };
94
125
  declare module '@deepseek-ai/cordis' {
95
126
  interface Context {
96
127
  agents: AgentsLike;
@@ -98,6 +129,7 @@ declare module '@deepseek-ai/cordis' {
98
129
  sessionQuery: SessionQueryLike;
99
130
  webServer: HttpServerLike;
100
131
  apiProxy: ApiProxyLike;
132
+ sessionController: SessionControllerLike;
101
133
  }
102
134
  interface Events {
103
135
  'agent/pre-step'(payload: {
@@ -106,10 +138,11 @@ declare module '@deepseek-ai/cordis' {
106
138
  readonly step: number;
107
139
  readonly signal: AbortSignal;
108
140
  }, next: () => Promise<unknown>): Promise<unknown>;
141
+ 'tools/execute'(exec: ToolExecutionLike, next: () => Promise<unknown>): Promise<unknown>;
109
142
  }
110
143
  }
111
144
  export declare const REWIND_HTTP_PATH = "/turn-rewind";
112
- /** Capture each turn before its opening user message can trigger model or tool work. */
145
+ /** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
113
146
  export declare class TurnCheckpointCoordinator {
114
147
  private readonly engine;
115
148
  private readonly captures;
@@ -118,7 +151,7 @@ export declare class TurnCheckpointCoordinator {
118
151
  private readonly skips;
119
152
  private readonly workspaceTails;
120
153
  constructor(engine: ChangeLedgerEngine);
121
- /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
154
+ /** Keep sidecar checkpoint work out of the Agent response waterfall. */
122
155
  install(ctx: Context): void;
123
156
  /** Current capture state for a session turn when no durable checkpoint exists yet. */
124
157
  state(sessionId: string, turn: number): {
@@ -126,19 +159,22 @@ export declare class TurnCheckpointCoordinator {
126
159
  readonly error?: string;
127
160
  readonly reason?: string;
128
161
  };
162
+ private startCapture;
129
163
  private capture;
164
+ /** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
165
+ private waitForOpenTurn;
130
166
  private serializeWorkspace;
131
167
  private recordFailure;
132
168
  }
133
169
  /** Register the same-origin preview/apply endpoint consumed by the browser half. */
134
170
  export declare function installRewindHttp(ctx: Context, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): void;
171
+ /** Build the exact-route handler as a testable unit. */
172
+ export declare function createRewindHttpHandler(ctx: ConversationRestartContext & {
173
+ readonly agents?: AgentsLike;
174
+ }, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
135
175
  export declare const MANAGE_HTTP_PATH = "/turn-rewind/manage";
136
176
  /** Register the same-origin storage-management endpoint consumed by the settings card. */
137
177
  export declare function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void;
138
178
  /** Build the storage-management route as a testable unit. */
139
179
  export declare function createManageHttpHandler(engine: ChangeLedgerEngine): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
140
- /** Build the exact-route handler as a testable unit. */
141
- export declare function createRewindHttpHandler(ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & {
142
- readonly agents?: AgentsLike;
143
- }, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
144
180
  export {};