dsh-as-aistudio 0.2.13 → 0.3.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.
@@ -0,0 +1,661 @@
1
+ // dsh-delete-turn host half (dsh-delete-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
+ //
3
+ // Source: ../dsh-delete-turn/src/index.js (v0.1.15, sha256 75377700ba2a6436)
4
+ // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
+ //
6
+ // src/shell.js imports the copy named after the component, which is why the
7
+ // studio installs with no runtime dependency on the component package.
8
+ // dsh-delete-turn - host half.
9
+ //
10
+ // Two loopback-only JSON routes:
11
+ //
12
+ // GET /dsh-delete-turn/state?sessionId=<id>
13
+ // POST /dsh-delete-turn/delete { sessionId, mode, seq?, messageId?, turn? }
14
+ //
15
+ // A deletion appends ONE turn-less `user/message` carrier carrying the official
16
+ // surface intent `{ surfaceOp: { op: 'replace', startSeq, endSeq } }` and the
17
+ // complete shadowed-node list in `sourceEventSeqs`. The carrier's content is
18
+ // empty (or one zero-width space, see below), so the addressed content leaves
19
+ // the derived context while the append-only log keeps every original byte. The
20
+ // carrier source names this plugin, which is how the browser half rebuilds its
21
+ // hidden-row ledger after a reload without any private sidecar.
22
+ //
23
+ // The fourth action (`mode: 'turn'`) deletes a WHOLE turn - prompt included - as
24
+ // one ordinary range replace: every live surface node of that turn (prompt,
25
+ // injected context, reply steps and tool results) is shadowed by a single
26
+ // turn-less carrier, and the later turns stay exactly where they are. No replay
27
+ // and no renumbering: the official surface contract needs neither, so the log
28
+ // grows by one event and nothing else.
29
+ //
30
+ // The module imports nothing from the DSH SDK: `sessions`, `sessionQuery` and
31
+ // `sessionController` are resolved through the cordis context at call time, so
32
+ // the plugin loads on any profile and degrades to a clear HTTP failure when a
33
+ // service is absent.
34
+ import { randomUUID } from 'node:crypto'
35
+ import { readFileSync } from 'node:fs'
36
+ import { createRequire } from 'node:module'
37
+ import { dirname } from 'node:path'
38
+ import {
39
+ PLUGIN_ID,
40
+ PlanError,
41
+ contentEditPairs,
42
+ deletableReplyTurns,
43
+ foldSurface,
44
+ hiddenEntriesOfFold,
45
+ isBusy,
46
+ openTurn,
47
+ planRange,
48
+ turnableSeqs,
49
+ } from './dsh-delete-turn.logic.js'
50
+
51
+ export const name = PLUGIN_ID
52
+
53
+ const ROUTE_PREFIX = '/dsh-delete-turn'
54
+ // A session id is an OPAQUE string to DSH: the host's own schema is
55
+ // `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
56
+ // stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
57
+ // real as a UUID one. A UUID-only test rejected those with a 400 - the panel
58
+ // then answered "状态加载失败" for a session that plainly exists, and a session
59
+ // list filtered by it hid them. The character class still refuses everything
60
+ // that could leave a path or a URL: `/`, `\`, `..` (the first character must be
61
+ // alphanumeric, so a leading dot is already out), whitespace and control
62
+ // characters.
63
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
64
+
65
+ // Stopping a running turn before the delete: the controller acknowledges the
66
+ // cancel at once, but the loop still has to unwind - the provider stream is
67
+ // cancelled, started tool calls commit their results in order, the partial
68
+ // answer lands as `interrupted: true`, `turn/end` is appended and flushed. The
69
+ // route therefore polls the log until the turn actually closes.
70
+ const STOP_TIMEOUT_MS = 15000
71
+ const STOP_POLL_MS = 250
72
+ let stopWait = { timeoutMs: STOP_TIMEOUT_MS, pollMs: STOP_POLL_MS }
73
+
74
+ /**
75
+ * Override the stop-and-delete wait budget. Production keeps the defaults; the
76
+ * suite shrinks them so the timeout branch stays cheap to test.
77
+ * @param options - `{ timeoutMs?, pollMs? }`.
78
+ */
79
+ export function configureStopWait(options) {
80
+ stopWait = {
81
+ timeoutMs: options && typeof options.timeoutMs === 'number' ? options.timeoutMs : STOP_TIMEOUT_MS,
82
+ pollMs: options && typeof options.pollMs === 'number' ? options.pollMs : STOP_POLL_MS,
83
+ }
84
+ }
85
+ // `turn` is the fourth action: the widest ordinary range replace (every live
86
+ // surface node of one turn). It dispatches through the same route and the same
87
+ // planner as the other three; nothing here is special-cased.
88
+ const MODES = new Set(['message', 'step', 'reply', 'turn'])
89
+
90
+ // The one adapter line that makes a deletion invisible: `dsh-llm-pi-ai` drops a
91
+ // user message whose converted content is empty (the official DeepSeek adapter
92
+ // already does this natively; upstream has not added the pi-ai skip yet). When
93
+ // that line is present, the carrier can be an empty content list and never
94
+ // reaches any model.
95
+ //
96
+ // Otherwise the carrier MUST carry a zero-width space. An empty content list is
97
+ // not a cheaper alternative, and this was measured rather than assumed: the
98
+ // session append accepts `content: []` (it derives to a user message with no
99
+ // blocks, priced at 4 tokens), but the adapter then hands the provider
100
+ // `{ role: 'user', content: '' }` and the provider refuses the whole request.
101
+ // Probed 2026-10-03 against the provider this repository deploys on
102
+ // (cline-pass -> api.cline.bot -> Vercel deepseek/deepseek-v4.1-flash):
103
+ //
104
+ // { role: 'user', content: '' } -> HTTP 400
105
+ // {"message":"user message must have content", "type":"invalid_request_error"}
106
+ // the same shape midway through a conversation -> HTTP 400, same error
107
+ // a normal user message through the same route -> past validation
108
+ // (fails only because max_tokens:1 produced no text)
109
+ //
110
+ // So the zero-width space is load-bearing, and its ~9 heuristic tokens (4 role
111
+ // framing + 4 block overhead + 1 char) are the price of a deletion every
112
+ // provider will accept. Dropping the character instead of the message is not an
113
+ // option: an empty string is what the provider rejects.
114
+ //
115
+ // A global capability probe cannot relax this either. The flag is decided once
116
+ // at load while the adapter in play depends on the session's provider, so a
117
+ // profile holding both a DeepSeek-adapter provider and an unpatched pi-ai one
118
+ // would write an empty carrier for the latter and break its deletions. The
119
+ // empty carrier is therefore used only when the one adapter this plugin can
120
+ // prove skips it is patched. The probe reads the installed adapter once at
121
+ // load; any failure falls back to the safe carrier.
122
+ const ADAPTER_PATCH_MARKER = 'dsh-delete-turn:skip-empty-user'
123
+
124
+ function adapterDropsEmptyUserContent() {
125
+ try {
126
+ const entry = process.argv[1]
127
+ const require = createRequire(entry ?? import.meta.url)
128
+ const resolved = require.resolve('@deepseek-ai/dsh-llm-pi-ai', { paths: entry ? [dirname(entry)] : [] })
129
+ return readFileSync(resolved, 'utf8').includes(ADAPTER_PATCH_MARKER)
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
135
+ const SILENT_CARRIER = adapterDropsEmptyUserContent()
136
+
137
+ /**
138
+ * The provider a session's last model request used, from its own log. The
139
+ * carrier shape depends on the ADAPTER behind that provider, so the decision has
140
+ * to be per session rather than once at load.
141
+ *
142
+ * @param events - the session's committed events.
143
+ * @returns the provider id, or null when the log does not name one.
144
+ */
145
+ export function providerOf(events) {
146
+ for (let index = events.length - 1; index >= 0; index -= 1) {
147
+ const event = events[index]
148
+ if (event === null || typeof event !== 'object') continue
149
+ const data = event.data
150
+ if (data === null || typeof data !== 'object') continue
151
+ if (event.type === 'model/selection' && typeof data.provider === 'string' && data.provider !== '') return data.provider
152
+ const header = data.header
153
+ const config = header !== null && typeof header === 'object' ? header.config : null
154
+ if (event.type === 'request/header' && config !== null && typeof config === 'object' && typeof config.provider === 'string' && config.provider !== '') {
155
+ return config.provider
156
+ }
157
+ }
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Whether the adapter serving `provider` drops an empty user message before it
163
+ * reaches the wire. The DeepSeek adapter does it natively (its converter skips
164
+ * `role === "user" | "assistant"` with no content) and is the one adapter whose
165
+ * extension hooks include `prepareExtensions`; pi-ai carries the same
166
+ * `resolveImageAccess` but no `prepareExtensions`.
167
+ *
168
+ * @param ctx - plugin context, for the `llm` registry.
169
+ * @param provider - provider id from the session log.
170
+ * @returns true only when the adapter in play is the proven one.
171
+ */
172
+ export function adapterDropsEmptyUserContentFor(ctx, provider) {
173
+ if (typeof provider !== 'string' || provider === '') return false
174
+ try {
175
+ const llm = ctx.get('llm')
176
+ const adapters = llm === null || llm === undefined ? null : llm.adapters
177
+ if (adapters === null || adapters === undefined || typeof adapters.get !== 'function') return false
178
+ const entry = adapters.get(provider)
179
+ const adapter = entry === null || entry === undefined ? null : entry.adapter
180
+ return adapter !== null && adapter !== undefined && typeof adapter.prepareExtensions === 'function'
181
+ } catch {
182
+ return false
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The carrier shape for one deletion: an empty content list when this session's
188
+ * adapter provably drops it, one zero-width space otherwise (the safe shape for
189
+ * an adapter that would send `{ role: 'user', content: '' }` and get a 400).
190
+ *
191
+ * @param ctx - plugin context.
192
+ * @param events - the session's committed events.
193
+ * @returns true when the empty carrier is safe for this session.
194
+ */
195
+ export function silentCarrierFor(ctx, events) {
196
+ if (adapterDropsEmptyUserContentFor(ctx, providerOf(events))) return true
197
+ // The load-time proof covers a pi-ai host with the local skip patch.
198
+ return SILENT_CARRIER
199
+ }
200
+
201
+ class HttpError extends Error {
202
+ constructor(status, code, message, details) {
203
+ super(message)
204
+ this.name = 'HttpError'
205
+ this.status = status
206
+ this.code = code
207
+ this.details = details
208
+ }
209
+ }
210
+
211
+ // A session id travels in two spellings: the raw uuid and `session-<uuid>`.
212
+ // The store, the persistence directories and the workspace rows disagree about
213
+ // which one they hold, so every lookup tries both.
214
+ function idVariants(sessionId) {
215
+ const out = new Set([sessionId])
216
+ if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
217
+ else out.add(`session-${sessionId}`)
218
+ return [...out]
219
+ }
220
+
221
+ function findLiveSession(ctx, sessionId) {
222
+ const sessions = ctx.get('sessions')
223
+ if (!sessions || typeof sessions.get !== 'function') return undefined
224
+ for (const variant of idVariants(sessionId)) {
225
+ const found = sessions.get(variant)
226
+ if (found) return found
227
+ }
228
+ return undefined
229
+ }
230
+
231
+ // Resolve the live Session that owns the append. An already-open session is
232
+ // used directly; a cold one is resumed through the official controller, which
233
+ // is exactly what the web UI does when the user opens it.
234
+ async function resolveSession(ctx, sessionId) {
235
+ const live = findLiveSession(ctx, sessionId)
236
+ if (live) return live
237
+ const controller = ctx.get('sessionController')
238
+ if (controller && typeof controller.resolveAgent === 'function') {
239
+ try {
240
+ const result = await controller.resolveAgent(sessionId)
241
+ if (result && result.agent && result.agent.session) return result.agent.session
242
+ } catch {
243
+ // fall through to the explicit failure below
244
+ }
245
+ }
246
+ return undefined
247
+ }
248
+
249
+ function eventsFromLive(session) {
250
+ if (session && typeof session.snapshotEvents === 'function') {
251
+ try {
252
+ const events = session.snapshotEvents()
253
+ if (Array.isArray(events)) return events
254
+ } catch {
255
+ // fall through to the query service
256
+ }
257
+ }
258
+ return undefined
259
+ }
260
+
261
+ // Live-preferred read through the public query service; the live session's own
262
+ // snapshot is the fallback when the service is absent.
263
+ async function readEvents(ctx, sessionId) {
264
+ const query = ctx.get('sessionQuery')
265
+ if (query && typeof query.readSession === 'function') {
266
+ try {
267
+ const snapshot = await query.readSession(sessionId)
268
+ if (snapshot && Array.isArray(snapshot.events)) return snapshot.events
269
+ } catch {
270
+ // fall through to the live snapshot
271
+ }
272
+ }
273
+ const events = eventsFromLive(findLiveSession(ctx, sessionId))
274
+ return events ?? null
275
+ }
276
+
277
+ function surfaceOf(ctx, sessionId, events) {
278
+ const live = findLiveSession(ctx, sessionId)
279
+ const nodes = live && live.surface && Array.isArray(live.surface.nodes) ? live.surface.nodes : undefined
280
+ return nodes ?? foldSurface(events).nodes
281
+ }
282
+
283
+ // The append is committed in memory the moment `session.append` returns; the
284
+ // persistence writer buffers asynchronously. Await the official durability
285
+ // checkpoint so a reload or a DSH restart still sees the deletion. A failed
286
+ // flush is not fatal — the event is already committed — but the request waits
287
+ // for the checkpoint when one exists.
288
+ async function flushSession(ctx, session) {
289
+ const errors = []
290
+ const sessions = ctx.get('sessions')
291
+ if (sessions && typeof sessions.flush === 'function') {
292
+ try {
293
+ await Promise.race([sessions.flush(session), new Promise((resolve) => setTimeout(resolve, 5000))])
294
+ return { flushed: true }
295
+ } catch (error) {
296
+ errors.push(String((error && error.message) || error))
297
+ }
298
+ } else {
299
+ errors.push('sessions.flush unavailable')
300
+ }
301
+ const persistence = ctx.get('sessionPersistence')
302
+ if (persistence && typeof persistence.flush === 'function') {
303
+ try {
304
+ await Promise.race([persistence.flush(), new Promise((resolve) => setTimeout(resolve, 5000))])
305
+ return { flushed: true }
306
+ } catch (error) {
307
+ errors.push(String((error && error.message) || error))
308
+ }
309
+ } else {
310
+ errors.push('sessionPersistence.flush unavailable')
311
+ }
312
+ return { flushed: false, flushError: errors.join(' | ') }
313
+ }
314
+
315
+ // --- operations --------------------------------------------------------------
316
+
317
+ // Turns opened purely as deletion bookkeeping: each carries one empty carrier
318
+ // system message and no real content. The host still renders a process row
319
+ // ("用时 N 秒") for them, so the browser half needs their numbers to hide them.
320
+ function markerTurnsOf(events) {
321
+ const turns = new Set()
322
+ for (const event of events) {
323
+ if (event.type !== 'system/message') continue
324
+ const data = event.data || {}
325
+ const source = (data.message && data.message.source) || {}
326
+ if (source.kind !== 'system-prompt' || source.plugin !== PLUGIN_ID) continue
327
+ if (typeof data.turn === 'number') turns.add(data.turn)
328
+ }
329
+ return [...turns].sort((a, b) => a - b)
330
+ }
331
+
332
+ async function stateOf(ctx, sessionId) {
333
+ const events = await readEvents(ctx, sessionId)
334
+ if (!events) {
335
+ // A named session with nothing in it yet is an EMPTY session, not a failed
336
+ // one: no row offers a delete, and the next refresh picks the log up once it
337
+ // exists. Answering 404 here is what the panel printed as
338
+ // "状态加载失败,稍后重试。".
339
+ return {
340
+ hidden: [],
341
+ surface: [],
342
+ replyTurns: [],
343
+ turnSeqs: [],
344
+ edits: [],
345
+ markerTurns: [],
346
+ live: false,
347
+ busy: false,
348
+ lastSeq: -1,
349
+ }
350
+ }
351
+ const folded = foldSurface(events)
352
+ // The live surface is what a delete will actually be validated against, so
353
+ // expose exactly that when the session is open.
354
+ const surfaceNodes = surfaceOf(ctx, sessionId, events)
355
+ return {
356
+ hidden: hiddenEntriesOfFold(folded, events),
357
+ // The current surface lets the browser half tell a row that still has
358
+ // context content from one whose content a compaction already removed;
359
+ // `replyTurns` narrows that to turns with an actually deletable reply.
360
+ surface: surfaceNodes,
361
+ replyTurns: deletableReplyTurns(events, surfaceNodes),
362
+ // Rows that may offer the turn-delete action. The list is produced by the
363
+ // SAME planner check the route uses (see turnableSeqs), so a row never
364
+ // advertises a turn delete the host would refuse.
365
+ turnSeqs: turnableSeqs(events, surfaceNodes),
366
+ // In-place rewrites by other producers (dsh-edit-turn): the transcript row
367
+ // stays anchored to the original seq while the context holds the
368
+ // replacement, so the browser half needs the chain to keep the row's
369
+ // action and to address the live node.
370
+ edits: contentEditPairs(folded, events),
371
+ // Bookkeeping turns a deletion opened (they must not show as empty
372
+ // process rows in the transcript).
373
+ markerTurns: markerTurnsOf(events),
374
+ live: Boolean(findLiveSession(ctx, sessionId)),
375
+ busy: isBusy(events),
376
+ lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
377
+ }
378
+ }
379
+
380
+ // The user pressed delete while a turn was still generating: stopping that turn
381
+ // is part of the click. The official entry is the session controller's `cancel`
382
+ // (it resolves the live Agent and calls `agent.cancel({ kind: 'user' },
383
+ // { keepInbox: true })`), so queued prompts survive and only the running turn is
384
+ // aborted. `cancel` needs the session ATTACHED, which `resolveSession` above
385
+ // already ensured. A compaction bracket has no stop semantics: it keeps the old
386
+ // busy refusal.
387
+ async function stopActiveTurn(ctx, sessionId, events) {
388
+ if (openTurn(events) === null) {
389
+ throw new HttpError(409, 'busy', 'the session is compacting; wait for it to finish')
390
+ }
391
+ const controller = ctx.get('sessionController')
392
+ if (!controller || typeof controller.cancel !== 'function') {
393
+ throw new HttpError(409, 'busy', 'the running turn cannot be stopped here')
394
+ }
395
+ let acknowledged = false
396
+ let lastError = null
397
+ // The controller keys agents by one spelling of the id; try both, exactly like
398
+ // every other lookup in this file.
399
+ for (const variant of idVariants(sessionId)) {
400
+ try {
401
+ await controller.cancel({ sessionId: variant })
402
+ acknowledged = true
403
+ break
404
+ } catch (error) {
405
+ lastError = error
406
+ }
407
+ }
408
+ if (!acknowledged) {
409
+ throw new HttpError(409, 'busy', `the stop request was refused: ${String((lastError && lastError.message) || lastError)}`)
410
+ }
411
+ const deadline = Date.now() + stopWait.timeoutMs
412
+ for (;;) {
413
+ await new Promise((resolve) => setTimeout(resolve, stopWait.pollMs))
414
+ const current = await readEvents(ctx, sessionId)
415
+ if (current && !isBusy(current)) return
416
+ if (Date.now() >= deadline) {
417
+ throw new HttpError(409, 'stop-timeout', 'the running turn did not stop in time')
418
+ }
419
+ }
420
+ }
421
+
422
+ async function deleteTarget(ctx, sessionId, body) {
423
+ const mode = typeof body.mode === 'string' ? body.mode : ''
424
+ if (!MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
425
+ const session = await resolveSession(ctx, sessionId)
426
+ if (!session || typeof session.append !== 'function') {
427
+ throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
428
+ }
429
+ let events = await readEvents(ctx, sessionId)
430
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
431
+ if (isBusy(events)) {
432
+ // A running turn is stopped first (the dialog says so), then the delete
433
+ // proceeds against the post-stop log.
434
+ await stopActiveTurn(ctx, sessionId, events)
435
+ events = await readEvents(ctx, sessionId)
436
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
437
+ if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
438
+ }
439
+
440
+ const surfaceNodes = surfaceOf(ctx, sessionId, events)
441
+ let plan
442
+ try {
443
+ plan = planRange(events, surfaceNodes, {
444
+ mode,
445
+ seq: typeof body.seq === 'number' ? body.seq : undefined,
446
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
447
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
448
+ })
449
+ } catch (error) {
450
+ if (error instanceof PlanError) {
451
+ const status = error.code === 'not-deletable' ? 400 : 409
452
+ throw new HttpError(status, error.code, error.message)
453
+ }
454
+ throw error
455
+ }
456
+
457
+ // The window may also cover nodes that belong to no part of the target (a
458
+ // foreign injected row, say). Deleting them is not refused - it is a decision
459
+ // the user must make explicitly: without `force` the route reports how many
460
+ // there are, and the client turns that into a second, forced confirmation.
461
+ if (plan.foreign.length > 0 && body.force !== true) {
462
+ throw new HttpError(409, 'range-not-clean', `the window would also remove ${plan.foreign.length} unrelated node(s)`, { foreign: plan.foreign.length })
463
+ }
464
+
465
+ // The live surface is the append authority; a node that vanished between the
466
+ // read and this check means another writer landed first.
467
+ for (const seq of plan.shadowed) {
468
+ if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
469
+ }
470
+
471
+ // The carrier must be a turn-less `user/message`.
472
+ //
473
+ // Opening a synthetic turn+step to host a model-invisible empty
474
+ // `system/message` corrupts the log: the agent loop advances its turn counter
475
+ // only from the turns it opens itself, so its next real turn reuses the
476
+ // number this plugin burned (`turn/start does not open the expected turn` on
477
+ // the next cold read) and every turn-number-keyed client hiding then swallows
478
+ // the reused turn's rows. That failure was reproduced in production; do not
479
+ // reintroduce a synthetic turn here.
480
+ //
481
+ // What the carrier holds depends on the installed pi-ai adapter: when it
482
+ // drops empty user content (see {@link adapterDropsEmptyUserContent}) the
483
+ // carrier is an empty content list and never reaches any model; otherwise the
484
+ // safest accepted content is a single ZERO-WIDTH SPACE (a readable marker
485
+ // like "[deleted]" is quoted back by the model, and empty content is refused
486
+ // by gateways whose adapter predates that skip).
487
+ let replacement
488
+ try {
489
+ replacement = session.append(
490
+ 'user/message',
491
+ {
492
+ id: randomUUID(),
493
+ role: 'user',
494
+ content: silentCarrierFor(ctx, events) ? [] : [{ type: 'text', text: '\u200b' }],
495
+ // v4 format: plugin wrappers are retired; the producer kind carries the id.
496
+ source: { kind: `plugin:${PLUGIN_ID}` },
497
+ },
498
+ {
499
+ surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
500
+ sourceEventSeqs: plan.shadowed,
501
+ },
502
+ )
503
+ } catch (error) {
504
+ throw new HttpError(409, 'stale', `the surface refused the replacement: ${String((error && error.message) || error)}`)
505
+ }
506
+ const flush = await flushSession(ctx, session)
507
+
508
+ return {
509
+ replacementSeq: replacement.seq,
510
+ ...flush,
511
+ hidden: plan.shadowed.map((seq) => ({ seq, mode: plan.mode })),
512
+ }
513
+ }
514
+
515
+ // --- http --------------------------------------------------------------------
516
+
517
+ function isLoopbackAddress(address) {
518
+ if (typeof address !== 'string' || address.length === 0) return false
519
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
520
+ }
521
+
522
+ function isLocalHostHeader(host) {
523
+ if (typeof host !== 'string' || host.length === 0) return false
524
+ const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
525
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1'
526
+ }
527
+
528
+ function sendJson(res, status, body) {
529
+ const payload = JSON.stringify(body)
530
+ res.writeHead(status, {
531
+ 'content-type': 'application/json; charset=utf-8',
532
+ 'content-length': Buffer.byteLength(payload),
533
+ })
534
+ res.end(payload)
535
+ }
536
+
537
+ function readBody(req) {
538
+ return new Promise((resolve, reject) => {
539
+ let data = ''
540
+ req.on('data', (chunk) => {
541
+ data += chunk
542
+ if (data.length > 1e6) req.destroy()
543
+ })
544
+ req.on('end', () => resolve(data))
545
+ req.on('error', reject)
546
+ req.on('aborted', () => reject(new Error('aborted')))
547
+ })
548
+ }
549
+
550
+ // Context rewriting is destructive for the model: loopback socket, loopback
551
+ // Host header, and a same-origin check when the browser sends Origin.
552
+ function guard(req, res) {
553
+ if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
554
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
555
+ return false
556
+ }
557
+ const host = req.headers.host
558
+ if (!isLocalHostHeader(host)) {
559
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
560
+ return false
561
+ }
562
+ const origin = req.headers.origin
563
+ if (typeof origin === 'string' && origin.length > 0) {
564
+ let originHost = null
565
+ try {
566
+ originHost = new URL(origin).host
567
+ } catch {
568
+ originHost = null
569
+ }
570
+ if (originHost !== host) {
571
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
572
+ return false
573
+ }
574
+ }
575
+ return true
576
+ }
577
+
578
+ function sessionIdFromQuery(url) {
579
+ try {
580
+ const value = new URL(url, 'http://localhost').searchParams.get('sessionId') || ''
581
+ return value.trim()
582
+ } catch {
583
+ return ''
584
+ }
585
+ }
586
+
587
+ function requireSessionId(value) {
588
+ if (!value) throw new HttpError(400, 'invalid', 'sessionId required')
589
+ if (!SESSION_ID_RE.test(value)) throw new HttpError(400, 'invalid', 'invalid session id')
590
+ return value
591
+ }
592
+
593
+ // --- plugin ------------------------------------------------------------------
594
+
595
+ export function apply(ctx) {
596
+ const registerRoutes = (webServer, fiber) => {
597
+ fiber.effect(() =>
598
+ webServer.register({
599
+ kind: 'exact',
600
+ path: `${ROUTE_PREFIX}/state`,
601
+ handler: async (req, res) => {
602
+ if (!guard(req, res)) return
603
+ if (req.method !== 'GET') {
604
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
605
+ return
606
+ }
607
+ try {
608
+ const sessionId = requireSessionId(sessionIdFromQuery(req.url))
609
+ sendJson(res, 200, { ok: true, ...(await stateOf(ctx, sessionId)) })
610
+ } catch (error) {
611
+ const status = error instanceof HttpError ? error.status : 500
612
+ const code = error instanceof HttpError ? error.code : 'internal'
613
+ const details = error instanceof HttpError && error.details !== undefined ? { details: error.details } : {}
614
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error), ...details })
615
+ }
616
+ },
617
+ }),
618
+ )
619
+
620
+ fiber.effect(() =>
621
+ webServer.register({
622
+ kind: 'exact',
623
+ path: `${ROUTE_PREFIX}/delete`,
624
+ handler: async (req, res) => {
625
+ if (!guard(req, res)) return
626
+ if (req.method !== 'POST') {
627
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
628
+ return
629
+ }
630
+ let body = {}
631
+ try {
632
+ const raw = await readBody(req)
633
+ if (raw) body = JSON.parse(raw)
634
+ } catch {
635
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
636
+ return
637
+ }
638
+ try {
639
+ const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
640
+ // Every mode - including the whole-turn delete - is one range
641
+ // replace, so one planner and one append path serve them all.
642
+ const result = await deleteTarget(ctx, sessionId, body)
643
+ sendJson(res, 200, { ok: true, ...result })
644
+ } catch (error) {
645
+ const status = error instanceof HttpError ? error.status : 500
646
+ const code = error instanceof HttpError ? error.code : 'internal'
647
+ const details = error instanceof HttpError && error.details !== undefined ? { details: error.details } : {}
648
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error), ...details })
649
+ }
650
+ },
651
+ }),
652
+ )
653
+ }
654
+
655
+ const webServer = ctx.get('webServer')
656
+ if (webServer) {
657
+ registerRoutes(webServer, ctx)
658
+ } else {
659
+ ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
660
+ }
661
+ }