dsh-edit-turn 0.5.0 → 0.5.3

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 CHANGED
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
80
  export const name = PLUGIN_ID
81
81
 
82
82
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.5.0'
83
+ export const PLUGIN_VERSION = '0.5.3'
84
84
 
85
85
  // A ring of the last few requests, answering "did the browser half even ask, and
86
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -100,7 +100,15 @@ export function recentRequests() {
100
100
  }
101
101
 
102
102
  const ROUTE_PREFIX = '/dsh-edit-turn'
103
- 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
103
+ // A session id is an OPAQUE string to DSH: the host's own schema is
104
+ // `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
105
+ // stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
106
+ // real as a UUID one. A UUID-only test rejected those with a 400, so the panel
107
+ // answered "状态加载失败" for a session that plainly exists. The character class
108
+ // still refuses everything that could leave a path or a URL: `/`, `\`, `..`
109
+ // (the first character must be alphanumeric, so a leading dot is already out),
110
+ // whitespace and control characters.
111
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
104
112
  const MAX_TEXT = 200_000
105
113
  const FLUSH_DEADLINE_MS = 5_000
106
114
 
@@ -162,6 +170,10 @@ export const Config =
162
170
  * replay splice otherwise; `patch` refuses to run without the
163
171
  * projection; `replay` always uses the old path. Prompt edits always use
164
172
  * the official surface replacement.
173
+ *
174
+ * There is deliberately no `replace` value: appending a fresh
175
+ * `assistant/message` with a replace op is refused by the session's own
176
+ * surface validation (see the note in the README).
165
177
  */
166
178
  replyMode: z.string().default('auto'),
167
179
  /**
@@ -1679,8 +1691,34 @@ export async function attachmentBytes(ctx, sessionId, seq, index) {
1679
1691
  async function stateOf(ctx, sessionId, config) {
1680
1692
  const events = await readEvents(ctx, sessionId)
1681
1693
  if (!events) {
1682
- noteRequest({ kind: 'state', sessionId, ok: false, code: 'session-not-found' })
1683
- throw new HttpError(404, 'session-not-found', 'no session log for this id')
1694
+ // A named session with nothing in it yet is an EMPTY session, not a failed
1695
+ // one: the editor opens with nothing to edit, and the next refresh picks the
1696
+ // log up once it exists. Answering 404 here is what the panel printed as
1697
+ // "状态加载失败,稍后重试。". The capability probe still runs, because "what
1698
+ // can this deployment do" does not depend on the log.
1699
+ noteRequest({ kind: 'state', sessionId, ok: true, code: 'session-empty' })
1700
+ const store = resolveAttachmentStore(ctx)
1701
+ return {
1702
+ hidden: [],
1703
+ edits: 0,
1704
+ revisions: [],
1705
+ surface: [],
1706
+ turns: [],
1707
+ replies: [],
1708
+ patches: [],
1709
+ mode: config.patch === true ? 'patch' : 'replay',
1710
+ live: false,
1711
+ busy: false,
1712
+ lastSeq: -1,
1713
+ capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1714
+ config: {
1715
+ confirm: config.confirm,
1716
+ replyMode: config.replyMode,
1717
+ patch: config.patch === true,
1718
+ editorStyle: config.editorStyle,
1719
+ },
1720
+ version: PLUGIN_VERSION,
1721
+ }
1684
1722
  }
1685
1723
  const folded = foldSurface(events)
1686
1724
  const ledger = rollbackLedger(events)
@@ -1868,7 +1906,9 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
1868
1906
  }
1869
1907
  const events = await readEvents(ctx, sessionId)
1870
1908
  if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1871
- if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1909
+ // No busy gate here: this path only appends the in-place patch record, which
1910
+ // leaves every surface node where it is. A running turn keeps writing its own
1911
+ // events; the record lands between them and the fold ignores it.
1872
1912
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1873
1913
  const patches = patchesOf(events)
1874
1914
  const plans = []
@@ -1900,7 +1940,11 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
1900
1940
  buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
1901
1941
  )
1902
1942
  } catch (error) {
1903
- throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
1943
+ const message = String((error && error.message) || error)
1944
+ if (isProjectionUnavailable(message)) {
1945
+ throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
1946
+ }
1947
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
1904
1948
  }
1905
1949
  const flush = await flushSession(ctx, session)
1906
1950
  return {
@@ -1927,22 +1971,23 @@ async function applyEdit(ctx, sessionId, body, config) {
1927
1971
  }
1928
1972
  const events = await readEvents(ctx, sessionId)
1929
1973
  if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1930
- if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1974
+ const busy = isBusy(events)
1931
1975
 
1932
1976
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1933
1977
  const patches = patchesOf(events)
1934
1978
  const patchMode = config.patch === true
1979
+ const request = {
1980
+ // A block from another step of the same turn names its own seq; that is
1981
+ // the message the projection has to patch.
1982
+ seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1983
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1984
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
1985
+ blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1986
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
1987
+ }
1935
1988
  let plan
1936
1989
  try {
1937
- plan = planRollback(events, surfaceNodes, {
1938
- // A block from another step of the same turn names its own seq; that is
1939
- // the message the projection has to patch.
1940
- seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1941
- messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1942
- turn: typeof body.turn === 'number' ? body.turn : undefined,
1943
- blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1944
- kind: typeof body.kind === 'string' ? body.kind : undefined,
1945
- }, { patches, patch: patchMode })
1990
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
1946
1991
  } catch (error) {
1947
1992
  if (error instanceof EditPlanError) {
1948
1993
  const status = error.code === 'not-editable' ? 400 : 409
@@ -1951,6 +1996,13 @@ async function applyEdit(ctx, sessionId, body, config) {
1951
1996
  throw error
1952
1997
  }
1953
1998
 
1999
+ // A running turn is a hard stop for every path that moves the surface: the
2000
+ // prompt rewrite shadows a message the turn may still be reading, and the
2001
+ // replay splice shadows a window and regenerates the tail. The in-place record
2002
+ // below moves nothing, so it is allowed to land while the agent is still
2003
+ // writing - the model reads it on its next fold.
2004
+ if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
2005
+
1954
2006
  // The in-place reply path: one small plugin record, interpreted by this
1955
2007
  // plugin's projection. Nothing leaves the surface, so there is no window to
1956
2008
  // check, no turn to open and no tail to replay. The record is append-only and
@@ -1980,8 +2032,19 @@ async function applyEdit(ctx, sessionId, body, config) {
1980
2032
  buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
1981
2033
  )
1982
2034
  } catch (error) {
1983
- throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
2035
+ const message = String((error && error.message) || error)
2036
+ // The projection is not registered for this session (a live reload that
2037
+ // dropped it). The same edit can land through the replay path, which
2038
+ // needs no projection - so fall through instead of failing the save.
2039
+ if (isProjectionUnavailable(message)) {
2040
+ // The fallback replays the tail, which a running turn forbids.
2041
+ if (busy) throw new HttpError(409, 'busy', 'the session is still working')
2042
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
2043
+ } else {
2044
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
2045
+ }
1984
2046
  }
2047
+ if (plan.patch === true) {
1985
2048
  const flush = await flushSession(ctx, session)
1986
2049
  return {
1987
2050
  kind: 'reply',
@@ -1999,6 +2062,7 @@ async function applyEdit(ctx, sessionId, body, config) {
1999
2062
  shadowed: [],
2000
2063
  ...flush,
2001
2064
  }
2065
+ }
2002
2066
  }
2003
2067
 
2004
2068
  // The live surface is the append authority; a node that vanished between the
@@ -2433,10 +2497,85 @@ function requireSessionId(value) {
2433
2497
  return value
2434
2498
  }
2435
2499
 
2500
+ /**
2501
+ * Whether the host refused a plugin record because its projection is no longer
2502
+ * registered for this session (a live reload that dropped it, or a session
2503
+ * loaded before the owning plugin). Such a save can still go through the replay
2504
+ * path, so it is not fatal.
2505
+ *
2506
+ * @param message - the host's error text.
2507
+ * @returns true when the record's projection is gone.
2508
+ */
2509
+ export function isProjectionUnavailable(message) {
2510
+ return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
2511
+ }
2512
+
2436
2513
  function failure(res, error) {
2514
+ const message = String((error && error.message) || error)
2515
+ // The host refuses to derive a session whose projection left the registry
2516
+ // (`SurfaceManager._assertProjections`). It is a known, self-explaining state
2517
+ // - the plugin was reloaded while that session was open - not an internal
2518
+ // fault, so it answers 409 with the host's own words and does not dump a stack
2519
+ // into the log on every request.
2520
+ if (isProjectionUnavailable(message)) {
2521
+ sendJson(res, 409, { ok: false, code: 'projection-missing', error: message })
2522
+ return
2523
+ }
2437
2524
  const status = error instanceof HttpError ? error.status : 500
2438
2525
  const code = error instanceof HttpError ? error.code : 'internal'
2439
- sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2526
+ // An unexpected throw is the only case the client cannot explain; leave the
2527
+ // stack in the host log so a report can name the cause.
2528
+ if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
2529
+ sendJson(res, status, { ok: false, code, error: message })
2530
+ }
2531
+
2532
+ /**
2533
+ * The one projection object this PROCESS uses, kept across plugin reloads.
2534
+ *
2535
+ * The host registry compares projection IDENTITY, not type:
2536
+ * `SurfaceManager._assertProjections()` (dsh-session/lib/index.js) throws
2537
+ * `session message projection "<type>" was removed or replaced` when a
2538
+ * projection a LIVE session already folded with is no longer in the registry
2539
+ * array, and `registerMessageProjection`'s own docstring states the rule:
2540
+ * "Disposing the contribution makes sessions that used it refuse further
2541
+ * derivation."
2542
+ *
2543
+ * A reload disposes the previous fiber, which splices the old object out of the
2544
+ * registry. Building a fresh object then leaves every open session that carries
2545
+ * a patch event unable to derive anything: the surface read throws, so this
2546
+ * plugin's own save/state paths answer 500, and so does any sibling that reads
2547
+ * `session.surface.nodes` (verified live - dsh-delete-turn's /state answered 500
2548
+ * for such a session after a reload). Reusing one object from a process-global
2549
+ * hands the registry the SAME definition back, so a reloaded plugin keeps the
2550
+ * open sessions foldable.
2551
+ *
2552
+ * The object is a pure, stateless interpreter, which is what makes the reuse
2553
+ * safe. One consequence is deliberate: after a code change the projection keeps
2554
+ * running the PREVIOUS definition for sessions that are already open, until the
2555
+ * session is reopened or DSH restarts. That is the better half of the trade -
2556
+ * the alternative is a session that cannot be read at all.
2557
+ *
2558
+ * @returns the process-wide projection definition.
2559
+ */
2560
+ function sharedPatchProjection() {
2561
+ const KEY = '__DSH_EDIT_TURN_PATCH_PROJECTION__'
2562
+ try {
2563
+ const stashed = globalThis[KEY]
2564
+ if (
2565
+ stashed !== undefined &&
2566
+ stashed !== null &&
2567
+ typeof stashed.project === 'function' &&
2568
+ stashed.type === PATCH_TYPE
2569
+ ) {
2570
+ return stashed
2571
+ }
2572
+ const fresh = patchProjection()
2573
+ globalThis[KEY] = fresh
2574
+ return fresh
2575
+ } catch {
2576
+ // A frozen or absent globalThis is not worth failing the load over.
2577
+ return patchProjection()
2578
+ }
2440
2579
  }
2441
2580
 
2442
2581
  // --- plugin ------------------------------------------------------------------
@@ -2477,7 +2616,7 @@ export function apply(ctx, config) {
2477
2616
  const existing = Array.isArray(registry)
2478
2617
  ? registry.find((entry) => entry && entry.type === PATCH_TYPE)
2479
2618
  : undefined
2480
- if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
2619
+ if (existing === undefined) sessionsService.registerMessageProjection(sharedPatchProjection())
2481
2620
  patch = true
2482
2621
  } catch (error) {
2483
2622
  if (resolved.replyMode === 'patch') throw error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",