dsh-edit-turn 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.
package/lib/index.js ADDED
@@ -0,0 +1,903 @@
1
+ // dsh-edit-turn - host half.
2
+ //
3
+ // Two loopback-only JSON routes:
4
+ //
5
+ // GET /dsh-edit-turn/state?sessionId=<id>
6
+ // POST /dsh-edit-turn/apply { sessionId, seq|messageId|turn, text }
7
+ //
8
+ // An edit is two official operations, in order:
9
+ //
10
+ // 1. ROLLBACK - append ONE surface event whose `surfaceOp` is
11
+ // `{ op: 'replace', startSeq, endSeq }` over the inclusive surface window
12
+ // "the addressed user message .. the last surface node". The official fold
13
+ // swaps that whole window for the replacing event, so every event recorded
14
+ // after the edit point leaves the derived model context while the
15
+ // append-only log keeps every original byte ("Committed events are never
16
+ // rewritten"). The window always ends at the last surface node, so an
17
+ // assistant message (which carries its own tool_use blocks) and the
18
+ // tool/result it produced are always shadowed together: no call/result
19
+ // pair can be left dangling.
20
+ //
21
+ // 2. RE-RUN - `ctx.sessionController.prompt(...)`, the one official prompt
22
+ // admission path. It resumes the Session when cold and starts exactly one
23
+ // new turn from the revised text.
24
+ //
25
+ // The replacement carrier defaults to an EMPTY `system/message`. The official
26
+ // format documents an empty later system node as dormant - "empty later nodes
27
+ // are dormant and project to no message" - so the rollback adds nothing the
28
+ // model can see and the derived context after an edit is exactly what it would
29
+ // be had the conversation really stopped at the edit point. `carrier:
30
+ // 'user/message'` switches to a short marker text instead; that shape is what
31
+ // an equivalent plugin already runs in production, so it is the fallback.
32
+ // Either way `sourceEventSeqs` must list the complete shadowed node set, which
33
+ // is why an `assistant/message` carrier is impossible (it forbids
34
+ // `sourceEventSeqs` outright).
35
+ //
36
+ // The module imports only `@deepseek-ai/schemastery` and
37
+ // `@deepseek-ai/dsh-tools` (both declared). Every DSH service is resolved
38
+ // through the cordis context at call time, so the plugin still loads on a
39
+ // profile that lacks one and degrades to a clear HTTP failure instead.
40
+ import { randomUUID } from 'node:crypto'
41
+ import z from '@deepseek-ai/schemastery'
42
+ import { defineTool } from '@deepseek-ai/dsh-tools'
43
+
44
+ /** Plugin id shared by the host and browser halves. */
45
+ export const PLUGIN_ID = 'dsh-edit-turn'
46
+
47
+ /** Cordis plugin name. */
48
+ export const name = PLUGIN_ID
49
+
50
+ /** Keep in sync with package.json and lib/client.js. */
51
+ export const PLUGIN_VERSION = '0.1.0'
52
+
53
+ const ROUTE_PREFIX = '/dsh-edit-turn'
54
+ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
55
+ const MAX_TEXT = 200_000
56
+ const PROMPT_DEADLINE_MS = 30_000
57
+ const FLUSH_DEADLINE_MS = 5_000
58
+
59
+ /**
60
+ * Model-visible text of the replacement carrier.
61
+ *
62
+ * A `user/message` carrier always projects into the model context, so the
63
+ * default states the one fact the revised context cannot express on its own:
64
+ * earlier turns were deliberately dropped. Keep it short and factual.
65
+ */
66
+ const DEFAULT_MARKER = '[dsh-edit-turn] The user revised an earlier message. Every turn after that message was rolled back; the revised message follows. Send your reply to the revised message.'
67
+
68
+ /** Deployment policy for the rollback. */
69
+ export const Config = z.object({
70
+ markerText: z.string().default(DEFAULT_MARKER),
71
+ confirm: z.boolean().default(true),
72
+ })
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // pure session-log logic (no DSH SDK imports; unit-testable under node --test)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** The four event types that may carry `surfaceOp` (official surface contract). */
79
+ const SURFACE_TYPES = new Set([
80
+ 'system/message',
81
+ 'user/message',
82
+ 'assistant/message',
83
+ 'tool/result',
84
+ ])
85
+
86
+ /**
87
+ * Whether one event participates in the model-visible surface.
88
+ * @param event - raw session event.
89
+ * @returns true for a message-producing event type.
90
+ */
91
+ export function isSurfaceEvent(event) {
92
+ const type = event && event.type
93
+ return typeof type === 'string' && SURFACE_TYPES.has(type)
94
+ }
95
+
96
+ /**
97
+ * Replay the surface operations of a complete log.
98
+ *
99
+ * Mirrors the official fold: `append` pushes the event onto the tail; a
100
+ * `replace` swaps the inclusive window between its two surface nodes for the
101
+ * replacing event. Replacements whose anchors are no longer present are skipped
102
+ * defensively - a corrupt log must not throw inside an HTTP handler.
103
+ *
104
+ * @param events - complete contiguous raw event log in seq order.
105
+ * @returns current surface seqs in model order plus every landed replacement.
106
+ */
107
+ export function foldSurface(events) {
108
+ const nodes = []
109
+ const replacements = []
110
+ for (const event of events) {
111
+ const op = event.surfaceOp
112
+ if (op === undefined) continue
113
+ if (op === 'append') {
114
+ nodes.push(event.seq)
115
+ continue
116
+ }
117
+ if (op === null || typeof op !== 'object' || op.op !== 'replace') continue
118
+ const startIdx = nodes.indexOf(op.startSeq)
119
+ const endIdx = nodes.indexOf(op.endSeq)
120
+ if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) continue
121
+ const shadowed = nodes.slice(startIdx, endIdx + 1)
122
+ nodes.splice(startIdx, endIdx - startIdx + 1, event.seq)
123
+ replacements.push({ seq: event.seq, startSeq: op.startSeq, endSeq: op.endSeq, shadowed })
124
+ }
125
+ return { nodes, replacements }
126
+ }
127
+
128
+ /**
129
+ * Map every event seq to the turn that encloses it.
130
+ *
131
+ * Turn brackets are the durable source for user messages (their payload has no
132
+ * turn field); assistant and tool events carry their own turn and override the
133
+ * bracket reading.
134
+ *
135
+ * @param events - complete contiguous raw event log.
136
+ * @returns seq -> turn number (undefined outside any turn).
137
+ */
138
+ export function turnIndex(events) {
139
+ const turnOf = new Map()
140
+ let current
141
+ for (const event of events) {
142
+ if (event.type === 'turn/start') {
143
+ current = event.data && event.data.turn
144
+ turnOf.set(event.seq, current)
145
+ continue
146
+ }
147
+ if (event.type === 'turn/end') {
148
+ turnOf.set(event.seq, current)
149
+ current = undefined
150
+ continue
151
+ }
152
+ const data = event.data
153
+ const explicit =
154
+ (event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'tool/call') &&
155
+ data &&
156
+ typeof data.turn === 'number'
157
+ ? data.turn
158
+ : undefined
159
+ turnOf.set(event.seq, explicit !== undefined ? explicit : current)
160
+ }
161
+ return turnOf
162
+ }
163
+
164
+ /** The turn still awaiting its `turn/end`, or null when every turn closed. */
165
+ export function openTurn(events) {
166
+ let open = null
167
+ for (const event of events) {
168
+ if (event.type === 'turn/start') open = event.data && event.data.turn
169
+ else if (event.type === 'turn/end' && (open === null || event.data.turn === open)) open = null
170
+ }
171
+ return open
172
+ }
173
+
174
+ /** Whether an operation is in flight that will still write the surface. */
175
+ export function isBusy(events) {
176
+ if (openTurn(events) !== null) return true
177
+ let compaction = false
178
+ for (const event of events) {
179
+ if (event.type === 'compaction/start') compaction = true
180
+ else if (event.type === 'compaction/end') compaction = false
181
+ }
182
+ return compaction
183
+ }
184
+
185
+ /**
186
+ * Durable message identity of one surface event.
187
+ * @param event - raw session event.
188
+ * @returns the message id, or undefined for an event without one.
189
+ */
190
+ export function messageIdOf(event) {
191
+ const data = event.data
192
+ if (!data || typeof data !== 'object') return undefined
193
+ if (event.type === 'user/message') return typeof data.id === 'string' ? data.id : undefined
194
+ if (event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'system/message') {
195
+ const message = data.message
196
+ return message && typeof message.id === 'string' ? message.id : undefined
197
+ }
198
+ return undefined
199
+ }
200
+
201
+ /**
202
+ * Whether one event is a human-authored prompt.
203
+ *
204
+ * Injected context, this plugin's own placeholder and compaction checkpoints
205
+ * are all `user/message` events too; only `source.kind === 'user'` is a prompt
206
+ * the user actually typed, so only those are editable.
207
+ *
208
+ * @param event - raw session event.
209
+ * @returns true for a human prompt.
210
+ */
211
+ export function isHumanPrompt(event) {
212
+ if (!event || event.type !== 'user/message') return false
213
+ const data = event.data
214
+ return Boolean(data && data.source && data.source.kind === 'user')
215
+ }
216
+
217
+ /**
218
+ * Read the plain text of a message payload, plus how many non-text parts it
219
+ * carries. Editing is text-only, so a caller must be told when a rewrite would
220
+ * drop attachments.
221
+ * @param event - raw session event.
222
+ * @returns `{ text, attachments }`.
223
+ */
224
+ export function readMessageText(event) {
225
+ const data = (event && event.data) || {}
226
+ const blocks = Array.isArray(data.content) ? data.content : []
227
+ let text = ''
228
+ let attachments = 0
229
+ for (const block of blocks) {
230
+ if (!block || typeof block !== 'object') continue
231
+ if (block.type === 'text' && typeof block.text === 'string') text += (text === '' ? '' : '\n') + block.text
232
+ else attachments += 1
233
+ }
234
+ return { text, attachments }
235
+ }
236
+
237
+ /**
238
+ * Rebuild this plugin's rollback ledger from the log alone.
239
+ *
240
+ * Every rollback is one replacement event whose message source is
241
+ * `{ kind: 'plugin', plugin: 'dsh-edit-turn' }`. A replacement landed by any
242
+ * other producer - compaction, for instance - is ignored.
243
+ *
244
+ * @param events - complete contiguous raw event log.
245
+ * @returns `{ hidden, edits }`: one hidden entry per shadowed seq, and one
246
+ * record per landed rollback.
247
+ */
248
+ export function rollbackLedger(events) {
249
+ const folded = foldSurface(events)
250
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
251
+ const turnOf = turnIndex(events)
252
+ const hidden = []
253
+ const edits = []
254
+ for (const replacement of folded.replacements) {
255
+ const event = bySeq.get(replacement.seq)
256
+ const data = event && event.data
257
+ const source = data && (data.source || (data.message && data.message.source))
258
+ if (!source || source.kind !== 'plugin' || source.plugin !== PLUGIN_ID) continue
259
+ for (const seq of replacement.shadowed) {
260
+ const turn = turnOf.get(seq)
261
+ hidden.push({ seq, turn: typeof turn === 'number' ? turn : null, replacement: replacement.seq })
262
+ }
263
+ edits.push({
264
+ replacementSeq: replacement.seq,
265
+ startSeq: replacement.startSeq,
266
+ endSeq: replacement.endSeq,
267
+ shadowed: replacement.shadowed,
268
+ })
269
+ }
270
+ return { hidden, edits }
271
+ }
272
+
273
+ /** Highest turn number recorded in the log, or undefined for an empty log. */
274
+ export function lastTurnOf(events) {
275
+ let last
276
+ for (const event of events) {
277
+ const turn = event.data && event.data.turn
278
+ if (typeof turn === 'number' && (last === undefined || turn > last)) last = turn
279
+ }
280
+ return last
281
+ }
282
+
283
+ /** Planner rejection with a machine code the HTTP layer forwards verbatim. */
284
+ export class EditPlanError extends Error {
285
+ constructor(code, message) {
286
+ super(message)
287
+ this.name = 'EditPlanError'
288
+ this.code = code
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Resolve one editing target inside a log.
294
+ * @param events - complete contiguous raw event log.
295
+ * @param request - `{ seq?, messageId?, turn? }`.
296
+ * @returns the target event.
297
+ * @throws {EditPlanError} with `not-editable` when no unique target exists.
298
+ */
299
+ function resolveTarget(events, request) {
300
+ let targetSeq = typeof request.seq === 'number' ? request.seq : undefined
301
+ if (targetSeq === undefined && typeof request.messageId === 'string' && request.messageId !== '') {
302
+ for (const event of events) {
303
+ if (messageIdOf(event) === request.messageId) {
304
+ targetSeq = event.seq
305
+ break
306
+ }
307
+ }
308
+ }
309
+ if (targetSeq === undefined && typeof request.turn === 'number') {
310
+ for (const event of events) {
311
+ if (!isHumanPrompt(event)) continue
312
+ if (turnIndex(events).get(event.seq) !== request.turn) continue
313
+ targetSeq = event.seq
314
+ break
315
+ }
316
+ }
317
+ if (targetSeq === undefined) throw new EditPlanError('not-editable', 'no editing target was found')
318
+ const target = events.find((event) => event.seq === targetSeq)
319
+ if (!target) throw new EditPlanError('not-editable', 'the target event does not exist')
320
+ return target
321
+ }
322
+
323
+ /**
324
+ * Turn one UI target into the canonical rollback window.
325
+ *
326
+ * The window is `[the addressed prompt .. the last surface node]`: the complete
327
+ * remainder of the conversation in surface order. It is contiguous by
328
+ * construction, it always ends on a completed turn's last node, and its left
329
+ * edge is exactly the prompt being rewritten, so the surviving prefix ends on
330
+ * the previous turn's boundary.
331
+ *
332
+ * @param events - complete contiguous raw event log.
333
+ * @param surfaceNodes - current surface seqs in model order.
334
+ * @param request - `{ seq?, messageId?, turn? }`.
335
+ * @returns `{ targetSeq, startSeq, endSeq, shadowed, turn, original, attachments }`.
336
+ * @throws {EditPlanError} with a stable code when the target cannot be planned.
337
+ */
338
+ export function planRollback(events, surfaceNodes, request) {
339
+ const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
340
+ const turnOf = turnIndex(events)
341
+ const target = resolveTarget(events, request, turnOf)
342
+ if (!nodeIndex.has(target.seq)) {
343
+ throw new EditPlanError('already-rolled-back', 'this message is no longer on the current surface')
344
+ }
345
+ // Surface node 0 is the system prompt head: the official append contract only
346
+ // lets a system/message rewrite exactly that node, and no UI row targets it.
347
+ if (target.seq === surfaceNodes[0]) {
348
+ throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
349
+ }
350
+ if (!isHumanPrompt(target)) {
351
+ throw new EditPlanError('not-editable', 'only messages you typed can be edited and re-run')
352
+ }
353
+ const startIdx = nodeIndex.get(target.seq)
354
+ const shadowed = surfaceNodes.slice(startIdx)
355
+ if (shadowed.length === 0 || shadowed[0] !== target.seq) {
356
+ throw new EditPlanError('not-editable', 'the target does not open a rollback window')
357
+ }
358
+ const { text, attachments } = readMessageText(target)
359
+ const turn = turnOf.get(target.seq)
360
+ return {
361
+ targetSeq: target.seq,
362
+ messageId: messageIdOf(target),
363
+ startSeq: shadowed[0],
364
+ endSeq: shadowed[shadowed.length - 1],
365
+ shadowed,
366
+ turn: typeof turn === 'number' ? turn : null,
367
+ original: text,
368
+ attachments,
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Every human prompt that can currently be edited.
374
+ *
375
+ * A prompt that already left the surface (an earlier rollback shadowed it, or a
376
+ * compaction consumed it) keeps its transcript row on purpose, so it must not
377
+ * be offered again.
378
+ *
379
+ * @param events - complete contiguous raw event log.
380
+ * @param surfaceNodes - current surface seqs in model order.
381
+ * @returns one entry per editable prompt, in conversation order.
382
+ */
383
+ export function editableTurns(events, surfaceNodes) {
384
+ const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
385
+ const turnOf = turnIndex(events)
386
+ const head = surfaceNodes[0]
387
+ const out = []
388
+ for (const event of events) {
389
+ if (!isHumanPrompt(event)) continue
390
+ if (!nodeIndex.has(event.seq)) continue
391
+ if (event.seq === head) continue
392
+ const { text, attachments } = readMessageText(event)
393
+ const turn = turnOf.get(event.seq)
394
+ out.push({
395
+ seq: event.seq,
396
+ turn: typeof turn === 'number' ? turn : null,
397
+ messageId: messageIdOf(event) ?? null,
398
+ text,
399
+ attachments,
400
+ })
401
+ }
402
+ return out
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // host service plumbing
407
+ // ---------------------------------------------------------------------------
408
+
409
+ class HttpError extends Error {
410
+ constructor(status, code, message) {
411
+ super(message)
412
+ this.name = 'HttpError'
413
+ this.status = status
414
+ this.code = code
415
+ }
416
+ }
417
+
418
+ // A session id travels in two spellings: the raw uuid and `session-<uuid>`.
419
+ // The store, the persistence directories and the workspace rows disagree about
420
+ // which one they hold, so every lookup tries both.
421
+ function idVariants(sessionId) {
422
+ const out = new Set([sessionId])
423
+ if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
424
+ else out.add(`session-${sessionId}`)
425
+ return [...out]
426
+ }
427
+
428
+ function findLiveSession(ctx, sessionId) {
429
+ const sessions = ctx.get('sessions')
430
+ if (!sessions || typeof sessions.get !== 'function') return undefined
431
+ for (const variant of idVariants(sessionId)) {
432
+ const found = sessions.get(variant)
433
+ if (found) return found
434
+ }
435
+ return undefined
436
+ }
437
+
438
+ // Resolve the live Session that owns the append. An already-open session is
439
+ // used directly; a cold one is resumed through the official controller, which
440
+ // is exactly what the web UI does when the user opens it.
441
+ async function resolveSession(ctx, sessionId) {
442
+ const live = findLiveSession(ctx, sessionId)
443
+ if (live) return live
444
+ const controller = ctx.get('sessionController')
445
+ if (controller && typeof controller.resolveAgent === 'function') {
446
+ try {
447
+ const result = await controller.resolveAgent(sessionId)
448
+ if (result && result.agent && result.agent.session) return result.agent.session
449
+ } catch {
450
+ // fall through to the explicit failure below
451
+ }
452
+ }
453
+ return undefined
454
+ }
455
+
456
+ function eventsFromLive(session) {
457
+ if (session && typeof session.snapshotEvents === 'function') {
458
+ try {
459
+ const events = session.snapshotEvents()
460
+ if (Array.isArray(events)) return events
461
+ } catch {
462
+ // fall through to the query service
463
+ }
464
+ }
465
+ return undefined
466
+ }
467
+
468
+ // Live-preferred read through the public query service; the live session's own
469
+ // snapshot is the fallback when the service is absent.
470
+ async function readEvents(ctx, sessionId) {
471
+ const query = ctx.get('sessionQuery')
472
+ if (query && typeof query.readSession === 'function') {
473
+ try {
474
+ const snapshot = await query.readSession(sessionId)
475
+ if (snapshot && Array.isArray(snapshot.events)) return snapshot.events
476
+ } catch {
477
+ // fall through to the live snapshot
478
+ }
479
+ }
480
+ const events = eventsFromLive(findLiveSession(ctx, sessionId))
481
+ return events ?? null
482
+ }
483
+
484
+ function surfaceOf(ctx, sessionId, events) {
485
+ const live = findLiveSession(ctx, sessionId)
486
+ const nodes = live && live.surface && Array.isArray(live.surface.nodes) ? live.surface.nodes : undefined
487
+ return nodes ?? foldSurface(events).nodes
488
+ }
489
+
490
+ // The append is committed in memory the moment `session.append` returns; the
491
+ // persistence writer buffers asynchronously. Await the official durability
492
+ // checkpoint so a reload or a DSH restart still sees the rollback.
493
+ async function flushSession(ctx, session) {
494
+ const errors = []
495
+ const sessions = ctx.get('sessions')
496
+ if (sessions && typeof sessions.flush === 'function') {
497
+ try {
498
+ await Promise.race([sessions.flush(session), new Promise((resolve) => setTimeout(resolve, FLUSH_DEADLINE_MS))])
499
+ return { flushed: true }
500
+ } catch (error) {
501
+ errors.push(String((error && error.message) || error))
502
+ }
503
+ } else {
504
+ errors.push('sessions.flush unavailable')
505
+ }
506
+ const persistence = ctx.get('sessionPersistence')
507
+ if (persistence && typeof persistence.flush === 'function') {
508
+ try {
509
+ await Promise.race([persistence.flush(), new Promise((resolve) => setTimeout(resolve, FLUSH_DEADLINE_MS))])
510
+ return { flushed: true }
511
+ } catch (error) {
512
+ errors.push(String((error && error.message) || error))
513
+ }
514
+ } else {
515
+ errors.push('sessionPersistence.flush unavailable')
516
+ }
517
+ return { flushed: false, flushError: errors.join(' | ') }
518
+ }
519
+
520
+ // --- operations --------------------------------------------------------------
521
+
522
+ async function stateOf(ctx, sessionId, config) {
523
+ const events = await readEvents(ctx, sessionId)
524
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
525
+ const folded = foldSurface(events)
526
+ const ledger = rollbackLedger(events)
527
+ const live = findLiveSession(ctx, sessionId)
528
+ return {
529
+ hidden: ledger.hidden,
530
+ edits: ledger.edits.length,
531
+ surface: folded.nodes,
532
+ turns: editableTurns(events, folded.nodes),
533
+ live: Boolean(live),
534
+ busy: isBusy(events),
535
+ lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
536
+ config: { confirm: config.confirm },
537
+ version: PLUGIN_VERSION,
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Build the event that carries one rollback replacement.
543
+ *
544
+ * @param plan - the planned window.
545
+ * @param lastTurn - highest turn number in the log, for a coherent bracket.
546
+ * @param config - resolved deployment policy.
547
+ * @returns `{ type, data }` for `session.append`.
548
+ */
549
+ export function buildCarrier(plan, lastTurn, config) {
550
+ const silent = config.carrier !== 'user/message'
551
+ if (silent) {
552
+ // An empty later system node is dormant and projects to no model message.
553
+ return {
554
+ type: 'system/message',
555
+ data: {
556
+ turn: typeof lastTurn === 'number' ? lastTurn : 0,
557
+ step: 1,
558
+ message: {
559
+ id: randomUUID(),
560
+ role: 'system',
561
+ content: [],
562
+ source: { kind: 'plugin', plugin: PLUGIN_ID },
563
+ },
564
+ },
565
+ }
566
+ }
567
+ return {
568
+ type: 'user/message',
569
+ data: {
570
+ id: randomUUID(),
571
+ role: 'user',
572
+ content: [{ type: 'text', text: config.markerText }],
573
+ source: { kind: 'plugin', plugin: PLUGIN_ID },
574
+ },
575
+ }
576
+ }
577
+
578
+ async function applyEdit(ctx, sessionId, body, config) {
579
+ const text = typeof body.text === 'string' ? body.text : ''
580
+ if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
581
+ if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
582
+
583
+ const session = await resolveSession(ctx, sessionId)
584
+ if (!session || typeof session.append !== 'function') {
585
+ throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
586
+ }
587
+ const events = await readEvents(ctx, sessionId)
588
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
589
+ if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
590
+
591
+ const surfaceNodes = surfaceOf(ctx, sessionId, events)
592
+ let plan
593
+ try {
594
+ plan = planRollback(events, surfaceNodes, {
595
+ seq: typeof body.seq === 'number' ? body.seq : undefined,
596
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
597
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
598
+ })
599
+ } catch (error) {
600
+ if (error instanceof EditPlanError) {
601
+ const status = error.code === 'not-editable' ? 400 : 409
602
+ throw new HttpError(status, error.code, error.message)
603
+ }
604
+ throw error
605
+ }
606
+
607
+ // The live surface is the append authority; a node that vanished between the
608
+ // read and this check means another writer landed first.
609
+ for (const seq of plan.shadowed) {
610
+ if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
611
+ }
612
+
613
+ const carrier = buildCarrier(plan, lastTurnOf(events), config)
614
+ let replacement
615
+ try {
616
+ // A replacement carrier has to be a surface event with non-empty
617
+ // `sourceEventSeqs` coverage of the whole shadowed window. An assistant
618
+ // message forbids `sourceEventSeqs` outright, so the carrier is either an
619
+ // empty `system/message` (default, dormant, invisible) or a short
620
+ // `user/message` marker.
621
+ replacement = session.append(carrier.type, carrier.data, {
622
+ surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
623
+ sourceEventSeqs: plan.shadowed,
624
+ })
625
+ } catch (error) {
626
+ throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
627
+ }
628
+ const flush = await flushSession(ctx, session)
629
+
630
+ // Step two: admit the revised prompt. This is the only official prompt path,
631
+ // so it also resumes a cold Session and starts exactly one new turn.
632
+ const controller = ctx.get('sessionController')
633
+ if (!controller || typeof controller.prompt !== 'function') {
634
+ return {
635
+ replacementSeq: replacement.seq,
636
+ promptAccepted: false,
637
+ promptError: 'sessionController.prompt unavailable',
638
+ shadowed: plan.shadowed,
639
+ ...flush,
640
+ }
641
+ }
642
+ try {
643
+ await controller.prompt(
644
+ {
645
+ requestId: randomUUID(),
646
+ sessionId,
647
+ mode: 'queue',
648
+ content: [{ type: 'text', text }],
649
+ },
650
+ AbortSignal.timeout(PROMPT_DEADLINE_MS),
651
+ )
652
+ } catch (error) {
653
+ return {
654
+ replacementSeq: replacement.seq,
655
+ promptAccepted: false,
656
+ promptError: String((error && error.message) || error),
657
+ shadowed: plan.shadowed,
658
+ ...flush,
659
+ }
660
+ }
661
+
662
+ return {
663
+ replacementSeq: replacement.seq,
664
+ promptAccepted: true,
665
+ shadowed: plan.shadowed,
666
+ ...flush,
667
+ }
668
+ }
669
+
670
+ // --- tool --------------------------------------------------------------------
671
+
672
+ /**
673
+ * Session id the calling agent runs in, when the tool context exposes one.
674
+ * @param exec - tool execution context.
675
+ * @returns the session id, or undefined when the caller has no agent.
676
+ */
677
+ function sessionIdFromExec(exec) {
678
+ const agent = exec && exec.agent
679
+ const session = agent && agent.session
680
+ if (session && typeof session.id === 'string' && session.id !== '') return session.id
681
+ if (agent && typeof agent.sessionId === 'string' && agent.sessionId !== '') return agent.sessionId
682
+ if (exec && typeof exec.sessionId === 'string' && exec.sessionId !== '') return exec.sessionId
683
+ return undefined
684
+ }
685
+
686
+ function registerTool(ctx) {
687
+ const tools = ctx.get('tools')
688
+ if (!tools || typeof tools.register !== 'function') return
689
+ tools.register(
690
+ defineTool({
691
+ name: 'edit_turn_targets',
692
+ description:
693
+ 'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite and re-run, with each prompt\'s original text. Read-only: it changes nothing.',
694
+ parameters: {
695
+ sessionId: {
696
+ type: 'string',
697
+ description: 'Session id to inspect. Defaults to the session this agent is running in.',
698
+ },
699
+ limit: { type: 'number', description: 'Maximum number of turns to return (default 20).' },
700
+ },
701
+ output: {
702
+ schema: { type: 'string' },
703
+ render: (_args, value) => [{ type: 'text', text: value }],
704
+ },
705
+ async execute(args, exec) {
706
+ const sessionId = typeof args.sessionId === 'string' && args.sessionId !== '' ? args.sessionId : sessionIdFromExec(exec)
707
+ if (sessionId === undefined) {
708
+ throw new Error('no sessionId was supplied and the calling agent has no session')
709
+ }
710
+ const events = await readEvents(ctx, sessionId)
711
+ if (!events) throw new Error(`no session log for ${sessionId}`)
712
+ const nodes = surfaceOf(ctx, sessionId, events)
713
+ const turns = editableTurns(events, nodes)
714
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
715
+ const shown = turns.slice(-limit)
716
+ const lines = [
717
+ `session ${sessionId}: ${turns.length} editable turn(s), ${isBusy(events) ? 'BUSY' : 'idle'}`,
718
+ ]
719
+ for (const turn of shown) {
720
+ const preview = turn.text.length > 160 ? `${turn.text.slice(0, 160)}...` : turn.text
721
+ lines.push(`- turn ${turn.turn} (seq ${turn.seq}, ${turn.attachments} attachment(s)): ${preview}`)
722
+ }
723
+ if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
724
+ return lines.join('\n')
725
+ },
726
+ }),
727
+ )
728
+ }
729
+
730
+ // --- http --------------------------------------------------------------------
731
+
732
+ function isLoopbackAddress(address) {
733
+ if (typeof address !== 'string' || address.length === 0) return false
734
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
735
+ }
736
+
737
+ function isLocalHostHeader(host) {
738
+ if (typeof host !== 'string' || host.length === 0) return false
739
+ const trimmed = host.trim().toLowerCase()
740
+ // A bracketed IPv6 literal keeps its colons, so the port can only be split
741
+ // off after the closing bracket, and nothing but an optional port may follow.
742
+ if (trimmed.startsWith('[')) {
743
+ const end = trimmed.indexOf(']')
744
+ if (end === -1) return false
745
+ const rest = trimmed.slice(end + 1)
746
+ if (rest !== '' && !/^:[0-9]+$/.test(rest)) return false
747
+ return trimmed.slice(1, end) === '::1'
748
+ }
749
+ const match = /^([^:]*)(?::([0-9]+))?$/.exec(trimmed)
750
+ if (match === null) return false
751
+ // Exact matches only: a name like `127.0.0.1.evil.test` must not pass.
752
+ return match[1] === 'localhost' || match[1] === '127.0.0.1' || match[1] === '::1'
753
+ }
754
+
755
+ function sendJson(res, status, body) {
756
+ const payload = JSON.stringify(body)
757
+ res.writeHead(status, {
758
+ 'content-type': 'application/json; charset=utf-8',
759
+ 'content-length': Buffer.byteLength(payload),
760
+ })
761
+ res.end(payload)
762
+ }
763
+
764
+ function readBody(req) {
765
+ return new Promise((resolve, reject) => {
766
+ let data = ''
767
+ req.on('data', (chunk) => {
768
+ data += chunk
769
+ if (data.length > 1e6) req.destroy()
770
+ })
771
+ req.on('end', () => resolve(data))
772
+ req.on('error', reject)
773
+ req.on('aborted', () => reject(new Error('aborted')))
774
+ })
775
+ }
776
+
777
+ // Rewriting the derived context is destructive for the model, so every route
778
+ // demands a loopback socket, a loopback Host header, and a same-origin check
779
+ // when the browser sends Origin.
780
+ function guard(req, res) {
781
+ if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
782
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
783
+ return false
784
+ }
785
+ const host = req.headers.host
786
+ if (!isLocalHostHeader(host)) {
787
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
788
+ return false
789
+ }
790
+ const origin = req.headers.origin
791
+ if (typeof origin === 'string' && origin.length > 0) {
792
+ let originHost = null
793
+ try {
794
+ originHost = new URL(origin).host
795
+ } catch {
796
+ originHost = null
797
+ }
798
+ if (originHost !== host) {
799
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
800
+ return false
801
+ }
802
+ }
803
+ return true
804
+ }
805
+
806
+ function sessionIdFromQuery(url) {
807
+ try {
808
+ return (new URL(url, 'http://localhost').searchParams.get('sessionId') || '').trim()
809
+ } catch {
810
+ return ''
811
+ }
812
+ }
813
+
814
+ function requireSessionId(value) {
815
+ if (!value) throw new HttpError(400, 'invalid', 'sessionId required')
816
+ if (!SESSION_ID_RE.test(value)) throw new HttpError(400, 'invalid', 'invalid session id')
817
+ return value
818
+ }
819
+
820
+ function failure(res, error) {
821
+ const status = error instanceof HttpError ? error.status : 500
822
+ const code = error instanceof HttpError ? error.code : 'internal'
823
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
824
+ }
825
+
826
+ // --- plugin ------------------------------------------------------------------
827
+
828
+ export function apply(ctx, config) {
829
+ const resolved = {
830
+ markerText:
831
+ config && typeof config.markerText === 'string' && config.markerText.trim() !== ''
832
+ ? config.markerText
833
+ : DEFAULT_MARKER,
834
+ carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
835
+ confirm: config && typeof config.confirm === 'boolean' ? config.confirm : true,
836
+ }
837
+
838
+ const registerRoutes = (webServer, fiber) => {
839
+ fiber.effect(() =>
840
+ webServer.register({
841
+ kind: 'exact',
842
+ path: `${ROUTE_PREFIX}/state`,
843
+ handler: async (req, res) => {
844
+ if (!guard(req, res)) return
845
+ if (req.method !== 'GET') {
846
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
847
+ return
848
+ }
849
+ try {
850
+ const sessionId = requireSessionId(sessionIdFromQuery(req.url))
851
+ sendJson(res, 200, { ok: true, ...(await stateOf(ctx, sessionId, resolved)) })
852
+ } catch (error) {
853
+ failure(res, error)
854
+ }
855
+ },
856
+ }),
857
+ )
858
+
859
+ fiber.effect(() =>
860
+ webServer.register({
861
+ kind: 'exact',
862
+ path: `${ROUTE_PREFIX}/apply`,
863
+ handler: async (req, res) => {
864
+ if (!guard(req, res)) return
865
+ if (req.method !== 'POST') {
866
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
867
+ return
868
+ }
869
+ let body = {}
870
+ try {
871
+ const raw = await readBody(req)
872
+ if (raw) body = JSON.parse(raw)
873
+ } catch {
874
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
875
+ return
876
+ }
877
+ try {
878
+ const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
879
+ sendJson(res, 200, { ok: true, ...(await applyEdit(ctx, sessionId, body, resolved)) })
880
+ } catch (error) {
881
+ failure(res, error)
882
+ }
883
+ },
884
+ }),
885
+ )
886
+ }
887
+
888
+ const webServer = ctx.get('webServer')
889
+ if (webServer) {
890
+ registerRoutes(webServer, ctx)
891
+ } else {
892
+ ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
893
+ }
894
+
895
+ const tools = ctx.get('tools')
896
+ if (tools) {
897
+ registerTool(ctx)
898
+ } else {
899
+ ctx.inject(['tools'], (sub) => registerTool(sub))
900
+ }
901
+ }
902
+
903
+ export default apply