@wjj-8283/dsh-temp-workspace 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,1446 @@
1
+ // @wjj-8283/dsh-temp-workspace — node half.
2
+ //
3
+ // Adds a "temporary workspace" (临时工作区) feature to the DeepSeek Harness web
4
+ // UI. A temporary workspace is an ordinary registry Workspace over a throwaway
5
+ // directory; every Session it owns is also temporary. On the next Harness
6
+ // start this plugin deletes those conversations (their session logs on disk)
7
+ // AND the workspace record itself, leaving no trace.
8
+ //
9
+ // Two halves:
10
+ // - Host half (this file): registers a browser-trust-fenced /temp-workspace/api
11
+ // route (create/delete/list + the settings config/pending/confirm/keep
12
+ // surfacing) and, at boot, cleans up the workspaces recorded as temporary.
13
+ // - Browser half (./client.js): injects a small icon button beside the
14
+ // sidebar's "Add workspace" (+) button. Clicking it asks this route to
15
+ // create a temporary workspace and opens a new Session in it. It also
16
+ // renders a Settings -> Plugins card and, when confirmBeforeDelete is on,
17
+ // a popup on boot before removing held workspaces.
18
+ //
19
+ // There is no public "delete session log" API in DSH (the persistence seam is
20
+ // append-only), so cleanup deletes the session directories on disk directly
21
+ // (computed through ctx.sessionPersistence.locate) before removing the
22
+ // workspace registration (workspaceRegistry.delete). Deleting a workspace
23
+ // registration retains the directory and session logs, so this plugin removes
24
+ // both explicitly. Deletion is driven by a durable marker (path + id) so a
25
+ // workspace whose registry record was already removed via the UI before a
26
+ // restart still reaps its leftover directory.
27
+
28
+ import { mkdir, readFile, writeFile, rename, rm, cp, stat, readdir } from 'node:fs/promises'
29
+ import { spawn } from 'node:child_process'
30
+ import { existsSync } from 'node:fs'
31
+ import { join, dirname, resolve, isAbsolute } from 'node:path'
32
+ import { homedir, tmpdir } from 'node:os'
33
+ import { randomUUID } from 'node:crypto'
34
+ import { promisify } from 'node:util'
35
+ import { zstdCompress, zstdDecompress } from 'node:zlib'
36
+
37
+ const zstdCompressAsync = promisify(zstdCompress)
38
+ const zstdDecompressAsync = promisify(zstdDecompress)
39
+
40
+ const PLUGIN_ID = 'dsh-temp-workspace'
41
+ const TEMP_TITLE = '临时工作区'
42
+ const SETTINGS_NS = 'dsh-temp-workspace'
43
+ // Default behavior when the user has not configured anything: delete on the
44
+ // next boot immediately, but confirm with the user before doing so.
45
+ const DEFAULT_SETTINGS = Object.freeze({
46
+ deleteMode: 'immediate', // 'immediate' | 'delayed'
47
+ deleteDelay: 3600, // seconds to wait after boot when deleteMode === 'delayed'
48
+ confirmBeforeDelete: true,
49
+ })
50
+ // A marker file under the DSH home listing every workspace currently marked
51
+ // temporary. Keep it next to the throwaway workspace dirs so a single recursive
52
+ // removal of the root dir never nukes the marker while a workspace exists.
53
+ const root = () => join(dshHome(), 'temp-workspaces')
54
+ const statePath = () => join(root(), 'state.json')
55
+
56
+ // The original (unwrapped) workspace registry `delete` method, captured in
57
+ // `apply`. The plugin's own cleanup path calls this so it never re-enters the
58
+ // temp-cleanup hook, while external (native sidebar) deletes go through the
59
+ // wrapped version that reaps the temp workspace's files first.
60
+ let registryDeleteUnwrapped = null
61
+
62
+ /** The DSH home: $DSH_HOME, else ~/.dsh (mirror of @deepseek-ai/dsh-home-paths). */
63
+ function dshHome() {
64
+ const env = process.env.DSH_HOME
65
+ if (typeof env === 'string' && env.trim() !== '') return env.trim()
66
+ return join(homedir(), '.dsh')
67
+ }
68
+
69
+ /**
70
+ * Mirror of the JSONL persistence store's `projectKey(cwd)` (kept in
71
+ * @deepseek-ai/dsh-session-persistence-jsonl). Returns the single filesystem-safe
72
+ * project directory name for a workspace cwd, e.g. a temp workspace at
73
+ * `/Users/wjj/.dsh/temp-workspaces/<uuid>` becoming
74
+ * `--Users-wjj-.dsh-temp-workspaces-<uuid>--`. Replicating the exact encoding
75
+ * is what lets us identify the session project dirs a temp workspace owns.
76
+ */
77
+ function projectKey(cwd) {
78
+ if (typeof cwd !== 'string' || cwd.length === 0) return '_no-cwd'
79
+ let readable = ''
80
+ let separatorRun = false
81
+ for (let i = 0; i < cwd.length; i++) {
82
+ const code = cwd.charCodeAt(i)
83
+ const ch = String.fromCharCode(code)
84
+ if (ch === '/' || ch === '\\' || ch === ':') {
85
+ if (!separatorRun) readable += '-'
86
+ separatorRun = true
87
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
88
+ readable += ch
89
+ separatorRun = false
90
+ } else {
91
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
92
+ separatorRun = false
93
+ }
94
+ }
95
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
96
+ }
97
+
98
+ /** The session project directory name that `cwd`'s sessions live under. */
99
+ function projectDirName(cwd) {
100
+ return projectKey(cwd)
101
+ }
102
+
103
+ /**
104
+ * Remove a session project directory once it holds nothing but junk (a leftover
105
+ * `.DS_Store`) — i.e. after every real session dir inside it is gone. This is
106
+ * what prevents the empty `--<cwd>--` directories from accumulating after a
107
+ * temp workspace's logs are cleaned or migrated. Only fires when there is no
108
+ * session content left, so it can never drop a live conversation.
109
+ */
110
+ async function pruneEmptyProjectDir(projectDir) {
111
+ try {
112
+ const entries = await readdir(projectDir)
113
+ if (entries.length === 0) {
114
+ await rm(projectDir, { recursive: true, force: true }).catch(() => {})
115
+ return
116
+ }
117
+ // macOS Finder often leaves a stray .DS_Store; treat that alone as empty.
118
+ if (entries.every((name) => name === '.DS_Store')) {
119
+ await rm(projectDir, { recursive: true, force: true }).catch(() => {})
120
+ }
121
+ } catch { /* already absent */ }
122
+ }
123
+
124
+ // ── durable marker state ────────────────────────────────────────────────────
125
+ /** Read the temp-workspace marker list (missing/corrupt file => []). */
126
+ async function readState() {
127
+ try {
128
+ const raw = await readFile(statePath(), 'utf8')
129
+ const parsed = JSON.parse(raw)
130
+ if (parsed && Array.isArray(parsed.tempWorkspaces)) return parsed.tempWorkspaces
131
+ } catch { /* first boot / absent / corrupt — start empty */ }
132
+ return []
133
+ }
134
+
135
+ /** Atomically persist the temp-workspace marker list. */
136
+ async function writeState(list) {
137
+ const dir = dirname(statePath())
138
+ await mkdir(dir, { recursive: true, mode: 0o700 })
139
+ const tmp = `${statePath()}.tmp`
140
+ await writeFile(tmp, JSON.stringify({ tempWorkspaces: list }, null, 2), { mode: 0o600 })
141
+ await rename(tmp, statePath())
142
+ }
143
+
144
+ // ── pending session re-attach (survives the restart the client prompts) ─────
145
+ // The registry's session→workspace grouping (bootstrap) runs only once on the
146
+ // first boot. Migrated sessions are moved + their cwd rewritten on disk, but the
147
+ // already-initialized registry never adds them to the new workspace's sessionIds.
148
+ // So we persist a "pending attach" list and re-attach on the next boot, when
149
+ // `replaceHeaderIndex` has indexed the migrated headers with the fresh cwd.
150
+ const pendingAttachPath = () => join(root(), 'pending-attach.json')
151
+
152
+ /** Read pending session-attach jobs (missing/corrupt file => []). */
153
+ async function readPendingAttach() {
154
+ try {
155
+ const raw = await readFile(pendingAttachPath(), 'utf8')
156
+ const parsed = JSON.parse(raw)
157
+ if (parsed && Array.isArray(parsed.jobs)) return parsed.jobs
158
+ } catch { /* first boot / absent / corrupt */ }
159
+ return []
160
+ }
161
+
162
+ /** Atomically persist the pending session-attach jobs. */
163
+ async function writePendingAttach(jobs) {
164
+ const dir = dirname(pendingAttachPath())
165
+ await mkdir(dir, { recursive: true, mode: 0o700 })
166
+ const tmp = `${pendingAttachPath()}.tmp`
167
+ await writeFile(tmp, JSON.stringify({ jobs }, null, 2), { mode: 0o600 })
168
+ await rename(tmp, pendingAttachPath())
169
+ }
170
+
171
+ /** Append one pending-attach job (workspaceId + migrated session ids). */
172
+ async function addPendingAttach(workspaceId, path, sessionIds) {
173
+ if (sessionIds.length === 0) return
174
+ const jobs = await readPendingAttach()
175
+ jobs.push({ workspaceId, path, sessionIds, at: new Date().toISOString() })
176
+ await writePendingAttach(jobs)
177
+ }
178
+
179
+ /**
180
+ * Re-attach every persisted migrated session to its new workspace. Returns the
181
+ * number of sessions attached; clears the pending list even for jobs that could
182
+ * not be resolved (so a stale record never blocks later ones).
183
+ */
184
+ async function reattachPendingSessions(ctx) {
185
+ const jobs = await readPendingAttach()
186
+ if (jobs.length === 0) return 0
187
+ let attached = 0
188
+ for (const job of jobs) {
189
+ if (job === null || typeof job !== 'object' || !Array.isArray(job.sessionIds)) continue
190
+ const ws = ctx.workspaceRegistry.get(job.workspaceId)
191
+ if (ws === undefined) {
192
+ console.warn(`[${PLUGIN_ID}] pending re-attach: workspace ${job.workspaceId} no longer exists; dropping`)
193
+ continue
194
+ }
195
+ for (const sessionId of job.sessionIds) {
196
+ if (typeof sessionId !== 'string') continue
197
+ try {
198
+ await ws.attachSession(sessionId)
199
+ attached += 1
200
+ } catch (error) {
201
+ console.error(`[${PLUGIN_ID}] re-attach ${sessionId} failed`, error)
202
+ }
203
+ }
204
+ }
205
+ await writePendingAttach([])
206
+ return attached
207
+ }
208
+
209
+ // ── workspace helpers ───────────────────────────────────────────────────────
210
+ /** Project a registry Workspace into the wire WorkspaceView shape. */
211
+ function toView(ws) {
212
+ return {
213
+ workspaceId: ws.id,
214
+ path: ws.path,
215
+ title: ws.title,
216
+ sessionIds: [...ws.sessionIds],
217
+ createdAt: ws.createdAt,
218
+ updatedAt: ws.updatedAt,
219
+ }
220
+ }
221
+
222
+ /** Remove from disk every session log living under `cwd` (the temp workspace dir). */
223
+ async function deleteSessionsUnderPath(ctx, cwd) {
224
+ // Remove the whole cwd project directory rather than matching individual
225
+ // headers. The JSONL store groups a workspace's session logs under
226
+ // `<sessionsRoot>/--<projectKey(cwd)>--`, so deleting that directory clears
227
+ // every conversation for this temp workspace regardless of header state (a
228
+ // blank session may not be materialized as a header yet, and a stale header
229
+ // cache could otherwise make the cwd match miss a live log). Because the temp
230
+ // workspace's throwaway dir is unique (under ~/.dsh/temp-workspaces/<uuid>),
231
+ // its projectKey can never collide with a real workspace's project dir.
232
+ let sessionsRoot
233
+ try { sessionsRoot = ctx.sessionPersistence.root } catch { return }
234
+ if (typeof sessionsRoot !== 'string' || sessionsRoot === '') return
235
+ const projectDir = join(sessionsRoot, projectKey(cwd))
236
+ await rm(projectDir, { recursive: true, force: true }).catch(() => {})
237
+ }
238
+
239
+ /** Remove from disk every session log a workspace record still claims. */
240
+ async function deleteWorkspaceSessions(ctx, ws) {
241
+ // Same project-dir strategy as deleteSessionsUnderPath: this workspace's logs
242
+ // all live under its cwd's project directory, so remove it wholesale (more
243
+ // robust than matching headers by id, which misses blank/unmaterialized ones).
244
+ await deleteSessionsUnderPath(ctx, ws.path)
245
+ }
246
+
247
+ /**
248
+ * Best-effort removal of LIVE sessions owned by a temp workspace, so a delete
249
+ * that runs while the host still holds those sessions actually drops the
250
+ * conversations from the UI — not just from disk. This is the gap behind the
251
+ * "cleared the temp workspace but the conversations are still there" bug:
252
+ * the boot confirm dialog / delayed auto-delete / sidebar delete can all run
253
+ * in a host process that never truly restarted (a page reload keeps the host
254
+ * and its in-memory sessions alive), and DSH's own workspace delete only
255
+ * removes the registration.
256
+ *
257
+ * Order matters for workspaces with MORE THAN ONE live session. The detach
258
+ * below emits `session/disposed`, and the persistence coordinator answers
259
+ * that with a retirement flush (`retire` → `flush` → `initFor`), which
260
+ * MATERIALIZES the session's log file when it is not on disk yet. If that
261
+ * flush races the directory deletion (the caller removes the project dir
262
+ * right after this, and a late flush re-materializes a log in its place),
263
+ * the conversations turn into unreadable Ungrouped leftovers. So before
264
+ * detaching we:
265
+ * 1. cancel the agent and wait for the aborted turn to settle
266
+ * (`whenIdle`, mirroring the agent loop's own dispose: the cancelled
267
+ * turn appends `turn/end` before going idle);
268
+ * 2. flush the session (`sessions.flush`) so every pending write — the
269
+ * materialized header included — is durably on disk NOW;
270
+ * then detach, whose retirement flush has nothing left to write and cannot
271
+ * re-create the log after the directory is removed.
272
+ *
273
+ * After the detach we also prune the session's projection-cache row (the
274
+ * detach checkpoints one last time, so the prune retries briefly) and the
275
+ * legacy per-session cache file — the "no trace" half of this function; the
276
+ * boot-time orphan sweep is the authoritative net for anything that slips
277
+ * through.
278
+ *
279
+ * Every step is optional and fail-soft:
280
+ * - the live-store detach (the primary removal) emits `session/disposed`
281
+ * so the client drops the conversation at once;
282
+ * - only when that internal detach is unavailable do we fall back to
283
+ * `archiveSession`, the registry's supported "hidden from every grouping
284
+ * surface" mechanism (the sidebar groups, flat list, and search all
285
+ * filter archived ids) — the conversation disappears anyway, at the
286
+ * cost of a durable archived-id entry the orphan sweep later reclaims.
287
+ * @returns the number of live sessions removed.
288
+ */
289
+ async function removeLiveSessions(ctx, cwd) {
290
+ if (typeof cwd !== 'string' || cwd === '') return 0
291
+ const sessions = (typeof ctx.get === 'function' && ctx.get('sessions')) || ctx.sessions
292
+ const agents = (typeof ctx.get === 'function' && ctx.get('agents')) || ctx.agents
293
+ if (!sessions || typeof sessions.list !== 'function') return 0
294
+ const owned = []
295
+ for (const session of sessions.list()) {
296
+ if (session && typeof session === 'object' && session.header && session.header.cwd === cwd) owned.push(session)
297
+ }
298
+ let removed = 0
299
+ const detachedIds = []
300
+ for (const session of owned) {
301
+ try {
302
+ const id = session.id
303
+ // 1. Stop any running turn and wait for it to settle, so its final
304
+ // `turn/end` append lands before we flush (no log resurrection).
305
+ try {
306
+ const agent = agents && typeof agents.get === 'function' ? agents.get(id) : undefined
307
+ if (agent && typeof agent.cancel === 'function') {
308
+ agent.cancel({ kind: 'disposed' })
309
+ if (typeof agent.whenIdle === 'function') {
310
+ await Promise.race([agent.whenIdle(), settleTimeout(2000)])
311
+ }
312
+ }
313
+ } catch (error) {
314
+ console.error(`[${PLUGIN_ID}] cancel live agent ${id} failed`, error)
315
+ }
316
+ // 2. Drain every pending write to disk NOW, so the retirement flush
317
+ // triggered by the detach below has nothing to write and cannot
318
+ // re-materialize the log after the directory is deleted.
319
+ try {
320
+ if (typeof sessions.flush === 'function') await sessions.flush(session)
321
+ } catch { /* best-effort */ }
322
+ // 3. Detach — the primary removal (emits session/disposed).
323
+ let detached = false
324
+ try {
325
+ const store = sessions.store
326
+ const entry = store && typeof store.get === 'function' ? store.get(id) : undefined
327
+ if (entry !== undefined && typeof sessions.detachEntered === 'function') {
328
+ sessions.detachEntered(entry)
329
+ detached = true
330
+ removed += 1
331
+ detachedIds.push(id)
332
+ }
333
+ } catch { /* fall through to the archive fallback */ }
334
+ // 4. Fallback when the live-store detach is unavailable: archive, so
335
+ // the conversation is hidden from every grouping surface anyway.
336
+ if (!detached) {
337
+ try {
338
+ if (ctx.workspaceRegistry && typeof ctx.workspaceRegistry.archiveSession === 'function') {
339
+ await ctx.workspaceRegistry.archiveSession(id)
340
+ }
341
+ } catch { /* best-effort */ }
342
+ }
343
+ } catch (error) {
344
+ console.error(`[${PLUGIN_ID}] live-session cleanup failed for ${session?.id}`, error)
345
+ }
346
+ }
347
+ // Let the detach-induced retirement flushes and projection-cache detach
348
+ // checkpoints settle on the microtask queue so the caller's directory
349
+ // deletion never races a late write.
350
+ await settleTimeout(0)
351
+ // Prune the durable projection-cache rows. The detach checkpoints the
352
+ // session one last time (flushSoft on session/disposed), so retry briefly
353
+ // until the row stays gone; the boot-time orphan sweep is the net.
354
+ for (const id of detachedIds) {
355
+ for (let attempt = 0; attempt < 3; attempt += 1) {
356
+ await settleTimeout(80)
357
+ await pruneSessionResidue(ctx, id)
358
+ }
359
+ }
360
+ return removed
361
+ }
362
+
363
+ /** A promise that resolves after `ms`, never rejects — for bounded best-effort waits. */
364
+ function settleTimeout(ms) {
365
+ return new Promise((resolve) => setTimeout(resolve, ms))
366
+ }
367
+
368
+ /** The legacy per-session projection-cache file path for one session id (older storage layout). */
369
+ function legacyProjectionCacheFile(id) {
370
+ return join(dshHome(), 'storages', 'session_projcache', 'sessions', `${id}.json`)
371
+ }
372
+
373
+ /** Remove one session's durable projection-cache residue (row + legacy file), fail-soft. */
374
+ async function pruneSessionResidue(ctx, id) {
375
+ if (typeof id !== 'string' || id === '') return
376
+ try {
377
+ const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
378
+ if (cache && cache.table && typeof cache.table.delete === 'function') {
379
+ await cache.table.delete(id)
380
+ }
381
+ } catch { /* best-effort */ }
382
+ await rm(legacyProjectionCacheFile(id), { force: true }).catch(() => {})
383
+ }
384
+
385
+ /**
386
+ * Hide the COLD (persisted-but-not-attached) sessions of a temp workspace from
387
+ * every grouping surface by archiving them. Deleting their logs alone leaves
388
+ * a stale entry in an already-open browser: cold sessions are not in the live
389
+ * session store, so the detach in `removeLiveSessions` cannot emit the
390
+ * `session/disposed` → `host/session-removed` event that would drop them —
391
+ * they linger in the sidebar as unreadable Ungrouped conversations until a
392
+ * reload. Archiving is the supported counterpart: the archive-set change
393
+ * pushes `host/archived-sessions-changed`, and the sidebar (groups, flat
394
+ * list, and search) filters archived ids, so the stale entries disappear
395
+ * immediately. The durable archived-id entry is reclaimed by the boot-time
396
+ * orphan sweep (`pruneOrphanTempResidue`), which finds the session's
397
+ * projection-cache row.
398
+ *
399
+ * Runs BEFORE the logs are deleted (the session catalog is read from disk).
400
+ * @returns the number of sessions archived.
401
+ */
402
+ async function archiveColdSessions(ctx, cwd) {
403
+ if (typeof cwd !== 'string' || cwd === '') return 0
404
+ const sessions = (typeof ctx.get === 'function' && ctx.get('sessions')) || ctx.sessions
405
+ const live = new Set()
406
+ if (sessions && typeof sessions.list === 'function') {
407
+ for (const session of sessions.list()) {
408
+ if (session && session.header && session.header.cwd === cwd) live.add(session.id)
409
+ }
410
+ }
411
+ let archived = 0
412
+ let headers
413
+ try { headers = await ctx.sessionPersistence.list() } catch { return 0 }
414
+ for (const meta of headers) {
415
+ if (meta.cwd !== cwd) continue
416
+ if (live.has(meta.id)) continue // live sessions are detached, not archived
417
+ try {
418
+ if (ctx.workspaceRegistry && typeof ctx.workspaceRegistry.archiveSession === 'function') {
419
+ await ctx.workspaceRegistry.archiveSession(meta.id)
420
+ archived += 1
421
+ }
422
+ } catch { /* best-effort */ }
423
+ }
424
+ return archived
425
+ }
426
+
427
+ /**
428
+ * Remove a temp workspace's on-disk footprints WITHOUT touching the workspace
429
+ * registry: its session logs under its cwd project dir, its throwaway dir, and
430
+ * its marker entry. This is the file-reaping half shared by `deleteTempWorkspace`
431
+ * and by the registry-delete hook (which runs when a temp workspace is removed
432
+ * via the native sidebar delete — DSH's own registry delete retains the
433
+ * directory and every session log, so without this the conversations would
434
+ * survive). Safe to call for an already-unregistered temp workspace.
435
+ */
436
+ async function reapTempWorkspaceFiles(ctx, entry) {
437
+ const { workspaceId, path } = entry ?? {}
438
+ if (typeof path === 'string' && path !== '') {
439
+ await removeLiveSessions(ctx, path)
440
+ await archiveColdSessions(ctx, path)
441
+ await deleteSessionsUnderPath(ctx, path)
442
+ await rm(path, { recursive: true, force: true }).catch(() => {})
443
+ }
444
+ if (typeof workspaceId === 'string' && workspaceId !== '') {
445
+ const list = (await readState()).filter((item) => item.workspaceId !== workspaceId)
446
+ await writeState(list)
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Delete one temporary workspace. `entry` comes from the marker and carries the
452
+ * recorded `path`, which is the single source of truth for the throwaway dir —
453
+ * deleting it must NOT depend on the registry record still existing. When a
454
+ * user removes the temp workspace via the UI before restart, the registry
455
+ * record is already gone but the folder (and any sessions it owned) remain;
456
+ * this path-driven deletion is what reaps that leftover.
457
+ */
458
+ async function deleteTempWorkspace(ctx, entry) {
459
+ const { workspaceId, path } = entry
460
+ const ws = ctx.workspaceRegistry.get(workspaceId)
461
+ if (ws !== undefined) {
462
+ // Tear down live sessions first: a running agent must be stopped before
463
+ // its log directory is removed, or it would re-materialize the log and
464
+ // the conversation would survive the delete. Then archive the cold
465
+ // (unattached) sessions so an already-open browser drops them too.
466
+ await removeLiveSessions(ctx, ws.path)
467
+ await archiveColdSessions(ctx, ws.path)
468
+ await deleteWorkspaceSessions(ctx, ws)
469
+ // Use the unwrapped delete so the temp-cleanup hook does not re-enter.
470
+ const del = registryDeleteUnwrapped ?? ctx.workspaceRegistry.delete.bind(ctx.workspaceRegistry)
471
+ await del(workspaceId)
472
+ }
473
+ await reapTempWorkspaceFiles(ctx, { workspaceId, path: path || ws?.path })
474
+ }
475
+
476
+ /**
477
+ * Permanently keep a temp workspace: move its files INTO a user-chosen folder
478
+ * (used directly — no extra subfolder is created) and re-register that folder as
479
+ * an ordinary (permanent) workspace named after the folder. This de-temps the
480
+ * workspace — it is no longer in the temp marker and so is never auto-deleted.
481
+ *
482
+ * The workspace's conversations are migrated too: each session log's header
483
+ * frame `cwd` is rewritten to the folder path and its log directory is moved
484
+ * into the new cwd's projectKey slot. Session logs are zstd-compressed, so only
485
+ * the independent header frame is rewritten; the event frames are preserved
486
+ * byte-for-byte. The session is not attached in-session (the registry caches
487
+ * headers from boot); it re-attaches on the next restart, which the client
488
+ * prompts for.
489
+ *
490
+ * @returns the new permanent workspace view.
491
+ */
492
+ async function moveTempWorkspace(ctx, entry, targetDir) {
493
+ const { workspaceId, path: sourcePath } = entry
494
+ const ws = ctx.workspaceRegistry.get(workspaceId)
495
+ const from = sourcePath || ws?.path || ''
496
+ if (from === '') throw new TempWorkspaceError('bad-path', 'no source directory recorded for this temp workspace', 400)
497
+ const target = resolve(targetDir)
498
+ if (target === from) throw new TempWorkspaceError('bad-path', 'source and destination are the same directory', 400)
499
+ // The user-chosen folder becomes the workspace, so its name is the title.
500
+ const title = describeTitle(target)
501
+
502
+ // Validate the destination: it must be an existing directory, and not already
503
+ // owned by a workspace (we never merge into an existing workspace's folder).
504
+ let targetStat
505
+ try { targetStat = await stat(target) } catch { targetStat = undefined }
506
+ if (targetStat === undefined || !targetStat.isDirectory()) {
507
+ throw new TempWorkspaceError('bad-path', `destination folder "${target}" is not an existing directory`, 400)
508
+ }
509
+ const existing = await ctx.workspaceRegistry.resolveByPath(target).catch(() => undefined)
510
+ if (existing !== undefined) {
511
+ throw new TempWorkspaceError('path-in-use', `destination folder "${target}" is already a workspace`, 409)
512
+ }
513
+
514
+ // Sessions owned by the temp workspace (header cwd === from). Capture BEFORE
515
+ // the directory move so the registry membership stays readable.
516
+ const oldSessionIds = await workspaceSessionIds(ctx, ws, from)
517
+
518
+ // Move the temp workspace's CONTENTS into the user-chosen folder. The folder
519
+ // already exists, so we copy its children into it, then remove the source.
520
+ const children = await readdir(from)
521
+ for (const child of children) {
522
+ await cp(join(from, child), join(target, child), { recursive: true, force: true })
523
+ }
524
+ await rm(from, { recursive: true, force: true }).catch(() => {})
525
+
526
+ // Re-register as a permanent workspace at the user-chosen folder.
527
+ let created
528
+ try {
529
+ created = await ctx.workspaceRegistry.create(target, title)
530
+ } catch (error) {
531
+ // Roll back the move so a failed registration does not strand the folder.
532
+ await rm(from, { recursive: true, force: true }).catch(() => {})
533
+ throw new TempWorkspaceError('move-failed', `moved "${from}" but could not register it at "${target}": ${error?.message ?? String(error)}`, 500)
534
+ }
535
+
536
+ // Migrate the conversations: rewrite each session log's header cwd and move
537
+ // its log dir into the new cwd's projectKey slot. The session is NOT attached
538
+ // here — the registry's header index was captured at boot (with the old cwd),
539
+ // so an in-session attach would validate against the old (now-deleted) temp
540
+ // path and fail. The registry's session→workspace grouping (bootstrap) only
541
+ // runs once on the first boot; on later boots it only rebuilds the id→cwd
542
+ // index, so it does NOT add the migrated session to the new workspace's
543
+ // sessionIds. We therefore record a durable "pending attach" here and re-attach
544
+ // on the next boot (when the index has the fresh cwd), which the client already
545
+ // prompts the user to do.
546
+ let migrated = 0
547
+ const migratedIds = []
548
+ for (const sessionId of oldSessionIds) {
549
+ try {
550
+ const ok = await migrateSession(ctx, sessionId, from, target, 'zstd')
551
+ if (ok) {
552
+ migrated += 1
553
+ migratedIds.push(sessionId)
554
+ console.log(`[${PLUGIN_ID}] migrated session ${sessionId}; it will attach to "${target}" after the next restart`)
555
+ }
556
+ } catch (error) {
557
+ console.error(`[${PLUGIN_ID}] migrate session ${sessionId} failed`, error)
558
+ }
559
+ }
560
+
561
+ // Drop the temp marker entry (this workspace is no longer temporary).
562
+ const list = (await readState()).filter((item) => item.workspaceId !== workspaceId)
563
+ await writeState(list)
564
+
565
+ // Delete the OLD registry record if it still exists.
566
+ if (ws !== undefined && ws.id !== created.id) {
567
+ try { await ctx.workspaceRegistry.delete(workspaceId) } catch { /* best-effort */ }
568
+ }
569
+
570
+ // Record a pending attach so the next boot re-attaches these migrated sessions
571
+ // to the newly registered workspace (the registry does not auto-group them on
572
+ // an already-initialized boot).
573
+ if (migratedIds.length > 0) {
574
+ await addPendingAttach(created.id, target, migratedIds)
575
+ }
576
+
577
+ return { workspace: toView(created), from, target, migratedSessions: migrated }
578
+ }
579
+
580
+ /** A short display title from a path's base name. */
581
+ function describeTitle(p) {
582
+ const base = p.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
583
+ return base && base !== '' ? base : '临时工作区'
584
+ }
585
+
586
+ // ── session log migration (zstd-aware) ──────────────────────────────────────
587
+ // A DSH session log is a concatenation of independently-decodable Zstandard
588
+ // frames: frame 0 is the header line (a single newline-terminated JSON object
589
+ // carrying the immutable `cwd`), and the remaining frames are the event
590
+ // batches. To migrate a session to a new workspace we only need to rewrite
591
+ // frame 0's `cwd` and leave every event frame byte-for-byte intact. The frame
592
+ // scanner below only locates the FIRST complete frame (the header) — it never
593
+ // decodes the event frames.
594
+
595
+ const ZSTD_MAGIC_LE = 0xFD2FB528 // 0x28 0xB5 0x2F 0xFD stored little-endian
596
+
597
+ /** Locate the byte range [start, end) of the first complete zstd frame. */
598
+ function firstZstdFrame(buffer) {
599
+ const len = buffer.length
600
+ if (len < 4 || buffer.readUInt32LE(0) !== ZSTD_MAGIC_LE) {
601
+ throw new Error('session log is not a zstandard frame (invalid magic)')
602
+ }
603
+ let offset = 4
604
+ if (offset === len) throw new Error('session log zstd frame truncated in frame header')
605
+ const descriptor = buffer.readUInt8(offset)
606
+ offset += 1
607
+ if ((descriptor & 24) !== 0) throw new Error('session log zstd frame reserved header bit set')
608
+ const contentSizeFlag = descriptor >>> 6
609
+ const singleSegment = (descriptor & 32) !== 0
610
+ const checksum = (descriptor & 4) !== 0
611
+ const dictionaryFlag = descriptor & 3
612
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
613
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
614
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
615
+ if (len - offset < remainingHeaderBytes) throw new Error('session log zstd frame truncated in frame header')
616
+ offset += remainingHeaderBytes
617
+ for (;;) {
618
+ if (len - offset < 3) throw new Error('session log zstd frame truncated in blocks')
619
+ const blockHeader = buffer.readUIntLE(offset, 3)
620
+ offset += 3
621
+ const lastBlock = (blockHeader & 1) !== 0
622
+ const blockType = blockHeader >>> 1 & 3
623
+ const blockSize = blockHeader >>> 3
624
+ if (blockType === 3) throw new Error('session log zstd frame reserved block type')
625
+ offset += blockType === 1 ? 1 : blockSize
626
+ if (lastBlock) break
627
+ }
628
+ if (checksum) {
629
+ if (len - offset < 4) throw new Error('session log zstd frame truncated in checksum')
630
+ offset += 4
631
+ }
632
+ return { start: 0, end: offset }
633
+ }
634
+
635
+ /** zstd frame compression options (checksummed, matching the backend). */
636
+ function zstdOptions() {
637
+ // The backend passes its checksum options; a conservative default is fine too.
638
+ return { level: 3 }
639
+ }
640
+
641
+ /**
642
+ * Rewrite the `cwd` field in the header line of a session log and return the
643
+ * new raw artifact bytes. `buf` is the original file bytes; `artifactName`
644
+ * tells whether it is zstd or plaintext. `fromCwd`, when given, must match the
645
+ * current header cwd (aborts on mismatch). Returns null when nothing changed.
646
+ */
647
+ async function rewriteSessionCwdBytes(buf, artifactName, fromCwd, toCwd) {
648
+ const isZstd = artifactName.endsWith('.jsonl.zstd')
649
+ let plain
650
+ let tail = Buffer.alloc(0)
651
+ if (isZstd) {
652
+ let frame
653
+ try {
654
+ frame = firstZstdFrame(buf)
655
+ plain = (await zstdDecompressAsync(buf.subarray(frame.start, frame.end))).toString('utf8')
656
+ tail = buf.subarray(frame.end)
657
+ } catch {
658
+ // Fall back to treating the whole file as a single frame.
659
+ plain = (await zstdDecompressAsync(buf)).toString('utf8')
660
+ tail = Buffer.alloc(0)
661
+ }
662
+ } else {
663
+ plain = buf.toString('utf8')
664
+ }
665
+
666
+ const nl = plain.indexOf('\n')
667
+ if (nl === -1) return null
668
+ const headerText = plain.slice(0, nl)
669
+ const rest = plain.slice(nl)
670
+ let parsed
671
+ try { parsed = JSON.parse(headerText) } catch { return null }
672
+ if (typeof parsed !== 'object' || parsed === null) return null
673
+ if (fromCwd !== undefined && parsed.cwd !== fromCwd) return null
674
+ if (parsed.cwd === toCwd) return null
675
+ parsed.cwd = toCwd
676
+
677
+ const newHeader = JSON.stringify(parsed) + rest
678
+ if (isZstd) {
679
+ const newFrame = await zstdCompressAsync(Buffer.from(newHeader, 'utf8'), zstdOptions())
680
+ return Buffer.concat([newFrame, tail])
681
+ }
682
+ return Buffer.from(newHeader, 'utf8')
683
+ }
684
+
685
+ /** The physical artifact filename a session log uses for a given compression. */
686
+ function sessionArtifactName(compression) {
687
+ return compression === 'zstd' ? 'session.jsonl.zstd' : 'session.jsonl'
688
+ }
689
+
690
+ /**
691
+ * List the session ids owned by a workspace: its registry `sessionIds`
692
+ * (canonical-cwd filtered) plus any materialized header whose cwd equals the
693
+ * workspace path (defensive for a record the registry no longer claims).
694
+ */
695
+ async function workspaceSessionIds(ctx, ws, path) {
696
+ const ids = new Set(ws?.sessionIds ?? [])
697
+ try {
698
+ const headers = await ctx.sessionPersistence.list()
699
+ for (const meta of headers) if (meta.cwd === path) ids.add(meta.id)
700
+ } catch { /* ignore */ }
701
+ return [...ids]
702
+ }
703
+
704
+ /**
705
+ * Migrate one session to a new cwd: write a rewritten copy of its log into the
706
+ * new cwd's projectKey directory (header frame `cwd` updated, every event
707
+ * frame unchanged), then remove the old session directory. Non-destructive:
708
+ * the destination is written first; the old dir is only removed after success.
709
+ * @returns true on success.
710
+ */
711
+ async function migrateSession(ctx, sessionId, oldCwd, newCwd, compression) {
712
+ const oldLoc = ctx.sessionPersistence.locate({ id: sessionId, cwd: oldCwd })
713
+ const newLoc = ctx.sessionPersistence.locate({ id: sessionId, cwd: newCwd })
714
+ if (!oldLoc?.path || !newLoc?.path) return false
715
+
716
+ const oldDir = dirname(oldLoc.path)
717
+ const newDir = dirname(newLoc.path)
718
+
719
+ // Detect the physical artifact: the backend defaults to zstd, but a session
720
+ // may be stored plaintext (.jsonl). Try the given compression, then the other.
721
+ const preferred = sessionArtifactName(compression)
722
+ const alternate = compression === 'zstd' ? 'session.jsonl' : 'session.jsonl.zstd'
723
+ let artifactName = preferred
724
+ let exists = await stat(join(oldDir, preferred)).then(() => true, () => false)
725
+ if (!exists) {
726
+ const alt = await stat(join(oldDir, alternate)).then(() => true, () => false)
727
+ if (alt) artifactName = alternate
728
+ else return false // nothing materialized on disk — nothing to migrate
729
+ }
730
+
731
+ const oldArtifact = join(oldDir, artifactName)
732
+ const newArtifact = join(newDir, artifactName)
733
+
734
+ const buf = await readFile(oldArtifact).catch((error) => {
735
+ console.error(`[${PLUGIN_ID}] migrate read failed for ${sessionId}`, error)
736
+ return null
737
+ })
738
+ if (buf === null) return false
739
+ const newBytes = await rewriteSessionCwdBytes(buf, oldArtifact, oldCwd, newCwd)
740
+ if (newBytes === null) return false
741
+
742
+ try {
743
+ await mkdir(newDir, { recursive: true, mode: 0o700 })
744
+ await rm(newDir, { recursive: true, force: true }).catch(() => {})
745
+ await mkdir(newDir, { recursive: true, mode: 0o700 })
746
+ await writeFile(newArtifact, newBytes, { mode: 0o600 })
747
+ // Move any other session-owned artifacts (non-header) along verbatim.
748
+ await rm(oldDir, { recursive: true, force: true }).catch(() => {})
749
+ await pruneEmptyProjectDir(dirname(oldDir))
750
+ return true
751
+ } catch (error) {
752
+ console.error(`[${PLUGIN_ID}] migrate write failed for ${sessionId}`, error)
753
+ return false
754
+ }
755
+ }
756
+
757
+ // ── create / delete / list ──────────────────────────────────────────────────
758
+ /** Create a temporary workspace (throwaway dir + registry record + marker). */
759
+ async function createTempWorkspace(ctx) {
760
+ const dir = join(root(), randomUUID())
761
+ await mkdir(dir, { recursive: true, mode: 0o700 })
762
+ const ws = await ctx.workspaceRegistry.create(dir, TEMP_TITLE)
763
+
764
+ const list = await readState()
765
+ list.push({ workspaceId: ws.id, path: ws.path, createdAt: ws.createdAt })
766
+ await writeState(list)
767
+
768
+ return { workspace: toView(ws), created: true }
769
+ }
770
+
771
+ /** The temp-workspace marker entries (id + path + createdAt) for the browser. */
772
+ async function listTempWorkspaces() {
773
+ return (await readState()).filter((entry) => entry && typeof entry === 'object' && entry.workspaceId)
774
+ .map((entry) => ({ workspaceId: entry.workspaceId, path: entry.path, createdAt: entry.createdAt }))
775
+ }
776
+
777
+ /** Boot cleanup: remove every workspace (sessions + registration) recorded as temporary. */
778
+ async function cleanupTempWorkspaces(ctx) {
779
+ const list = await readState()
780
+ if (list.length === 0) return 0
781
+ let removed = 0
782
+ for (const entry of list) {
783
+ if (entry === null || typeof entry !== 'object') continue
784
+ try {
785
+ await deleteTempWorkspace(ctx, entry)
786
+ removed += 1
787
+ } catch (error) {
788
+ console.error(`[${PLUGIN_ID}] cleanup failed for ${entry.workspaceId}`, error)
789
+ }
790
+ }
791
+ // Clear the marker for every entry we processed, regardless of per-entry
792
+ // errors, so a permanently-broken record never blocks later cleanups.
793
+ const processed = new Set(list.map((entry) => entry.workspaceId).filter(Boolean))
794
+ const remaining = (await readState()).filter((entry) => !(entry.workspaceId !== undefined && processed.has(entry.workspaceId)))
795
+ await writeState(remaining)
796
+ return removed
797
+ }
798
+
799
+ /**
800
+ * One-time boot sweep: remove session project directories left behind by temp
801
+ * workspaces that no longer exist. The JSONL store removes individual session
802
+ * dirs and (before this fix) never reaped the now-empty `--<cwd>--` parent, so
803
+ * after repeated create/delete cycles `~/.dsh/sessions` fills with orphaned
804
+ * `--*.dsh-temp-workspaces-*--` folders. Any such project dir whose cwd is NOT
805
+ * still referenced by the temp marker is provably orphaned — its workspace's
806
+ * throwaway directory is already gone — so the whole project dir (sessions
807
+ * included) can be removed. Active temp workspaces (still in the marker) are
808
+ * untouched.
809
+ * @returns the number of orphaned project directories removed.
810
+ */
811
+ async function cleanupOrphanTempProjectDirs(ctx) {
812
+ let sessionsRoot
813
+ try { sessionsRoot = ctx.sessionPersistence.root } catch { return 0 }
814
+ if (typeof sessionsRoot !== 'string' || sessionsRoot === '') return 0
815
+
816
+ const active = new Set((await readState())
817
+ .filter((entry) => entry && typeof entry === 'object' && entry.path)
818
+ .map((entry) => projectDirName(entry.path)))
819
+
820
+ let removed = 0
821
+ let entries
822
+ try { entries = await readdir(sessionsRoot, { withFileTypes: true }) } catch { return 0 }
823
+ for (const entry of entries) {
824
+ if (!entry.isDirectory()) continue
825
+ const name = entry.name
826
+ // Only temp-workspace project dirs: `--...dsh-temp-workspaces-...--`. This
827
+ // filter deliberately skips ordinary project dirs (e.g. the user's real cwd,
828
+ // or the permanent-keep destination folders created while testing) so they
829
+ // are never touched.
830
+ if (!name.startsWith('--') || !name.endsWith('--')) continue
831
+ if (!name.includes('dsh-temp-workspaces-')) continue
832
+ if (active.has(name)) continue // a live temp workspace still owns this dir
833
+ const dir = join(sessionsRoot, name)
834
+ try {
835
+ await rm(dir, { recursive: true, force: true })
836
+ removed += 1
837
+ console.log(`[${PLUGIN_ID}] cleared orphaned temp-workspace session dir ${name}`)
838
+ } catch (error) {
839
+ console.error(`[${PLUGIN_ID}] could not clear orphaned dir ${name}`, error)
840
+ }
841
+ }
842
+
843
+ // Also reap orphaned throwaway directories directly under the temp root: the
844
+ // durable state.json is the source of truth for which throwaway dirs are live,
845
+ // so any `<uuid>` dir there that is no longer referenced is provably a leftover
846
+ // (its marker entry was already cleared by a prior delete). These are the
847
+ // actual "临时工作区" folders and would otherwise accumulate beside state.json.
848
+ const troot = root()
849
+ let tentries
850
+ try { tentries = await readdir(troot, { withFileTypes: true }) } catch { return removed }
851
+ const activePaths = new Set((await readState()).filter((e) => e && typeof e === 'object' && e.path).map((e) => e.path))
852
+ for (const entry of tentries) {
853
+ if (!entry.isDirectory()) continue
854
+ const dir = join(troot, entry.name)
855
+ if (activePaths.has(dir)) continue
856
+ // Never touch our own infra files if a directory somehow collides by name.
857
+ if (entry.name === 'state.json' || entry.name === 'pending-attach.json') continue
858
+ try {
859
+ await rm(dir, { recursive: true, force: true })
860
+ removed += 1
861
+ console.log(`[${PLUGIN_ID}] cleared orphaned throwaway dir ${entry.name}`)
862
+ } catch (error) {
863
+ console.error(`[${PLUGIN_ID}] could not clear orphaned throwaway dir ${entry.name}`, error)
864
+ }
865
+ }
866
+ return removed
867
+ }
868
+
869
+ /**
870
+ * One-time boot sweep that reclaims the DURABLE metadata residue a temp
871
+ * workspace leaves behind after its session dirs are gone: the projection
872
+ * cache rows (`session_projcache` domain), the archived-id entries the
873
+ * archive fallback adds, and the legacy per-session cache files from an
874
+ * older storage layout. These rows are identity-checked (so they never
875
+ * surface a ghost conversation by themselves), but they ARE a trace, and
876
+ * the plugin's contract is "no trace".
877
+ *
878
+ * A row is provably orphaned when its recorded `cwd` lives under the temp
879
+ * root (`~/.dsh/temp-workspaces`) and is NOT still referenced by the marker —
880
+ * its workspace's throwaway dir is already gone. Active temp workspaces
881
+ * (still in the marker) are untouched, and rows for real (non-temp) cwds
882
+ * are never considered. The archived-id removal is scoped the same way, so
883
+ * the user's intentional archives of real conversations are never touched.
884
+ * @returns the number of projection-cache rows pruned.
885
+ */
886
+ async function pruneOrphanTempResidue(ctx) {
887
+ const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
888
+ if (!cache || !cache.table || typeof cache.table.keys !== 'function' || typeof cache.table.get !== 'function') return 0
889
+ const active = new Set((await readState())
890
+ .filter((entry) => entry && typeof entry === 'object' && entry.path)
891
+ .map((entry) => entry.path))
892
+ const troot = root()
893
+
894
+ const orphanIds = []
895
+ for (const id of cache.table.keys()) {
896
+ const record = cache.table.get(id)
897
+ const cwd = record && record.identity ? record.identity.cwd : undefined
898
+ if (typeof cwd !== 'string') continue
899
+ if (cwd !== troot && !cwd.startsWith(troot + '/')) continue // not a temp-workspace cwd
900
+ if (active.has(cwd)) continue // a live temp workspace still owns this row
901
+ orphanIds.push(id)
902
+ }
903
+ if (orphanIds.length === 0) return 0
904
+
905
+ // Un-archive the orphaned temp sessions so the registry's durable archive
906
+ // set stops accumulating temp residue (scoped to exactly the ids above).
907
+ try {
908
+ const registry = ctx.workspaceRegistry
909
+ if (registry && registry.global && typeof registry.global.get === 'function' && typeof registry.global.set === 'function') {
910
+ const state = registry.global.get()
911
+ const removed = new Set(orphanIds)
912
+ const kept = state.archivedSessionIds.filter((id) => !removed.has(id))
913
+ if (kept.length !== state.archivedSessionIds.length) {
914
+ state.archivedSessionIds = kept
915
+ await registry.global.set(state)
916
+ }
917
+ }
918
+ } catch (error) {
919
+ console.error(`[${PLUGIN_ID}] could not un-archive orphaned temp sessions`, error)
920
+ }
921
+
922
+ let pruned = 0
923
+ for (const id of orphanIds) {
924
+ try {
925
+ if (typeof cache.table.delete === 'function') {
926
+ await cache.table.delete(id)
927
+ pruned += 1
928
+ }
929
+ } catch { /* best-effort */ }
930
+ await rm(legacyProjectionCacheFile(id), { force: true }).catch(() => {})
931
+ }
932
+ if (pruned > 0) console.log(`[${PLUGIN_ID}] pruned ${pruned} orphaned temp-workspace projection-cache row(s)`)
933
+ return pruned
934
+ }
935
+
936
+ // ── wire helpers (mirror dsh-better-sidebar / dsh-git-graph) ────────────────
937
+ export class TempWorkspaceError extends Error {
938
+ constructor(code, message, status = 400) {
939
+ super(message)
940
+ this.code = code
941
+ this.status = status
942
+ }
943
+ }
944
+
945
+ function readJsonBody(req) {
946
+ return new Promise((resolve, reject) => {
947
+ let body = ''
948
+ req.on('data', (chunk) => {
949
+ body += chunk
950
+ if (body.length > 4 * 1024 * 1024) {
951
+ req.destroy()
952
+ reject(new TempWorkspaceError('payload-too-large', 'request body too large', 413))
953
+ }
954
+ })
955
+ req.on('end', () => {
956
+ try { resolve(body === '' ? {} : JSON.parse(body)) } catch { reject(new TempWorkspaceError('bad-json', 'invalid JSON body', 400)) }
957
+ })
958
+ req.on('error', reject)
959
+ })
960
+ }
961
+
962
+ function writeJson(res, status, body) {
963
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
964
+ res.end(JSON.stringify(body))
965
+ }
966
+
967
+ function writeOk(res, value) {
968
+ writeJson(res, 200, { ok: true, value })
969
+ }
970
+
971
+ function writeError(res, error) {
972
+ const err = error instanceof Error ? error : new Error(String(error))
973
+ const status = err instanceof TempWorkspaceError ? err.status : 500
974
+ writeJson(res, status, { ok: false, error: { code: err.code ?? 'error', message: err.message } })
975
+ }
976
+
977
+ /** Same-origin / trusted-host check, mirroring the git-graph/better-sidebar fence. */
978
+ function isLoopback(hostname) {
979
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'
980
+ }
981
+
982
+ // ── self-restart (ported from dsh-market's proven restart.js) ────────────────
983
+ // The "Restart now" button triggers a real host-level restart, not just a page
984
+ // reload: a detached helper respawns the exact DSH invocation once this host
985
+ // releases its port, then this process terminates itself. Safety model mirrors
986
+ // dsh-market: direct same-origin loopback only, no forwarding headers, and the
987
+ // port is read off the request so the replacement takes over the same one.
988
+
989
+ /** The Node binary running this process (prefer argv0 when it is an absolute exe). */
990
+ function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
991
+ if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0)) return argv0
992
+ return execPath
993
+ }
994
+
995
+ /** The exact DSH entry this process booted with (mirrors dsh-market's dshArgv). */
996
+ function dshArgv() {
997
+ const entry = process.argv[1]
998
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
999
+ const abs = resolve(entry)
1000
+ return { file: nodeExecutable(), args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
1001
+ }
1002
+ return { file: 'dsh', args: [], cwd: undefined, viaShell: process.platform === 'win32' }
1003
+ }
1004
+
1005
+ /** The exact boot invocation the detached restart helper replays. */
1006
+ function restartLaunch() {
1007
+ const launch = dshArgv()
1008
+ return {
1009
+ ...launch,
1010
+ args: [...launch.args, ...process.argv.slice(2)],
1011
+ cwd: launch.cwd ?? process.cwd(),
1012
+ }
1013
+ }
1014
+
1015
+ /** Platform-correct spawn invocation (Windows gets a hidden console). */
1016
+ function respawnInvocation(launch, platform = process.platform) {
1017
+ if (platform !== 'win32') return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true }
1018
+ const quote = (p) => `'${p.replace(/'/g, "''")}'`
1019
+ return {
1020
+ file: 'powershell.exe',
1021
+ args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', [`& ${quote(launch.file)}`, ...launch.args.map(quote)].join(' ')],
1022
+ viaShell: false,
1023
+ detached: false,
1024
+ }
1025
+ }
1026
+
1027
+ /** Source for the detached helper that outlives this process and brings the replacement up. */
1028
+ function restartHelperSource(spawned, launch, logs, port) {
1029
+ return [
1030
+ "const { spawn } = require('node:child_process')",
1031
+ "const fs = require('node:fs')",
1032
+ "const net = require('node:net')",
1033
+ `const file = ${JSON.stringify(spawned.file)}`,
1034
+ `const args = ${JSON.stringify(spawned.args)}`,
1035
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
1036
+ `const viaShell = ${JSON.stringify(spawned.viaShell)}`,
1037
+ `const detached = ${JSON.stringify(spawned.detached)}`,
1038
+ `const logOut = ${JSON.stringify(logs.out)}`,
1039
+ `const logErr = ${JSON.stringify(logs.err)}`,
1040
+ `const port = ${JSON.stringify(port)}`,
1041
+ 'const sleep = (ms) => new Promise((r) => setTimeout(r, ms))',
1042
+ 'const note = (line) => { try { fs.appendFileSync(logErr, `[temp-workspace] ${line}\\n`) } catch {} }',
1043
+ 'const listening = () => new Promise((resolve) => {',
1044
+ ' const probe = net.connect({ host: "127.0.0.1", port })',
1045
+ ' const done = (value) => { probe.destroy(); resolve(value) }',
1046
+ ' probe.on("connect", () => done(true))',
1047
+ ' probe.on("error", () => done(false))',
1048
+ ' setTimeout(() => done(false), 500)',
1049
+ '})',
1050
+ 'const main = async () => {',
1051
+ ' if (port) {',
1052
+ ' const until = Date.now() + 30000',
1053
+ ' while (Date.now() < until && await listening()) await sleep(250)',
1054
+ ' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
1055
+ ' await sleep(300)',
1056
+ ' } else { await sleep(1500) }',
1057
+ ' let child',
1058
+ ' try {',
1059
+ ' const out = fs.openSync(logOut, "a")',
1060
+ ' const err = fs.openSync(logErr, "a")',
1061
+ ' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
1062
+ ' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
1063
+ ' child.unref()',
1064
+ ' } catch (error) {',
1065
+ ' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
1066
+ ' return',
1067
+ ' }',
1068
+ ' if (!port) { await sleep(3000); return }',
1069
+ ' const upBy = Date.now() + 20000',
1070
+ ' while (Date.now() < upBy && !(await listening())) await sleep(500)',
1071
+ ' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
1072
+ '}',
1073
+ 'main()',
1074
+ ].join('\n')
1075
+ }
1076
+
1077
+ /** The port this process serves on, read off the request that asked for the restart. */
1078
+ function servingPort(request) {
1079
+ const host = request.headers.host
1080
+ if (host === undefined) return null
1081
+ const match = /:(\d{1,5})$/u.exec(host)
1082
+ if (match === null) return null
1083
+ const port = Number(match[1])
1084
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
1085
+ }
1086
+
1087
+ /** Whether a process-control request came from this Web host on loopback. */
1088
+ function trustedRestartRequest(request) {
1089
+ const address = request.socket.remoteAddress
1090
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
1091
+ if (request.headers.forwarded !== undefined || request.headers['x-forwarded-for'] !== undefined || request.headers['x-real-ip'] !== undefined) return false
1092
+ const origin = request.headers.origin
1093
+ const host = request.headers.host
1094
+ if (origin === undefined || host === undefined) return false
1095
+ try {
1096
+ const parsed = new URL(origin)
1097
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
1098
+ } catch {
1099
+ return false
1100
+ }
1101
+ }
1102
+
1103
+ /** Relaunch the exact DSH entry, then stop this process. */
1104
+ async function scheduleRestart(port) {
1105
+ const launch = restartLaunch()
1106
+ const spawned = respawnInvocation(launch)
1107
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
1108
+ const logOut = join(tmpdir(), `temp-workspace-restart-${stamp}.out.log`)
1109
+ const logErr = join(tmpdir(), `temp-workspace-restart-${stamp}.err.log`)
1110
+ const helper = spawn(nodeExecutable(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
1111
+ detached: true,
1112
+ stdio: 'ignore',
1113
+ env: process.env,
1114
+ })
1115
+ helper.unref()
1116
+ // Give the response a moment to reach the browser, then terminate this host.
1117
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
1118
+ return { pid: process.pid, helperPid: helper.pid, logOut, logErr }
1119
+ }
1120
+
1121
+ export const inject = ['webServer', 'webRuntime', 'workspaceRegistry', 'sessionPersistence', 'settings', 'loader']
1122
+
1123
+ export async function apply(ctx) {
1124
+ const trustedHosts = ctx.webRuntime?.trustedHosts ?? []
1125
+
1126
+ // ── settings ─────────────────────────────────────────────────────────────
1127
+ // Register this plugin's settings namespace host-side (defaults, schema, and
1128
+ // the user-editable section in ~/.dsh/settings.yaml). The browser card reads
1129
+ // and writes the same namespace (via its own /temp-workspace/api/config
1130
+ // route); the boot cleanup below reads it to decide delete timing and whether
1131
+ // to confirm first. Mirrors dsh-workspace-auto-approval's use of the loader
1132
+ // for the schemastery builder.
1133
+ let settingsScope
1134
+ try {
1135
+ const schemaModule = await ctx.loader.import('@deepseek-ai/schemastery')
1136
+ const z = schemaModule?.default ?? schemaModule
1137
+ const schema = z.object({
1138
+ deleteMode: z.union([z.const('immediate'), z.const('delayed')]).default('immediate'),
1139
+ deleteDelay: z.number().min(0).default(3600),
1140
+ confirmBeforeDelete: z.boolean().default(true),
1141
+ })
1142
+ settingsScope = ctx.settings.register(SETTINGS_NS, schema)
1143
+ } catch (error) {
1144
+ console.error(`[${PLUGIN_ID}] settings registration failed; using defaults`, error)
1145
+ settingsScope = undefined
1146
+ }
1147
+
1148
+ /** Read the resolved settings, merging schema defaults over any missing key. */
1149
+ function readSettings() {
1150
+ const value = (() => {
1151
+ try { return settingsScope === undefined ? undefined : settingsScope.get() } catch { return undefined }
1152
+ })()
1153
+ return {
1154
+ deleteMode: value?.deleteMode ?? DEFAULT_SETTINGS.deleteMode,
1155
+ deleteDelay: Number(value?.deleteDelay ?? DEFAULT_SETTINGS.deleteDelay),
1156
+ confirmBeforeDelete: value?.confirmBeforeDelete ?? DEFAULT_SETTINGS.confirmBeforeDelete,
1157
+ }
1158
+ }
1159
+
1160
+ // ── native-delete hook ───────────────────────────────────────────────────
1161
+ // DSH's workspace registry delete retains the workspace directory AND every
1162
+ // session log (it only removes the registration). So deleting a temp
1163
+ // workspace via the sidebar would leave its conversations behind until a
1164
+ // boot-time cleanup. Wrap the registry delete so that, whenever the deleted
1165
+ // workspace is one recorded in the temp marker, we immediately reap its
1166
+ // conversations + throwaway dir + marker entry. Non-temp workspaces (not in
1167
+ // the marker) are unaffected, and the plugin's own cleanup path calls the
1168
+ // unwrapped delete to avoid re-entering this hook.
1169
+ {
1170
+ const nativeDelete = ctx.workspaceRegistry.delete.bind(ctx.workspaceRegistry)
1171
+ registryDeleteUnwrapped = nativeDelete
1172
+ ctx.workspaceRegistry.delete = async (id) => {
1173
+ const result = await nativeDelete(id)
1174
+ try {
1175
+ const entry = (await readState()).find((item) => item?.workspaceId === id)
1176
+ if (entry !== undefined) await reapTempWorkspaceFiles(ctx, entry)
1177
+ } catch (error) {
1178
+ console.error(`[${PLUGIN_ID}] temp-workspace cleanup after delete failed for ${id}`, error)
1179
+ }
1180
+ return result
1181
+ }
1182
+ }
1183
+
1184
+ const fence = (req) => {
1185
+ try {
1186
+ const authority = (req.headers?.host ?? '')
1187
+ const hostname = authority.replace(/:\d+$/, '')
1188
+ if (isLoopback(hostname)) return true
1189
+ return trustedHosts.some((entry) => (entry ?? '') === hostname || (entry ?? '').replace(/:\d+$/, '') === hostname)
1190
+ } catch {
1191
+ return false
1192
+ }
1193
+ }
1194
+
1195
+ // ── boot cleanup (settings-driven) ───────────────────────────────────────
1196
+ // Read the marker + resolved settings and decide what to do on this boot:
1197
+ // - confirmBeforeDelete ON : never auto-delete; expose the pending set and
1198
+ // wait for the browser to confirm (delete) or keep it. Nothing is removed
1199
+ // until the user answers, so an unattended boot never loses work.
1200
+ // - confirmBeforeDelete OFF : delete automatically — immediately, or after
1201
+ // deleteDelay seconds when deleteMode === 'delayed'.
1202
+ // The pending list + timing are captured so the /pending route can drive the
1203
+ // browser popup without re-reading the marker on every poll.
1204
+ const pending = new Map() // workspaceId -> marker entry awaiting confirmation
1205
+ // Whether the user already answered the boot prompt this host-lifetime
1206
+ // (keep / delete / permanent-keep). Once answered, /pending reports nothing
1207
+ // pending so the dialog never re-appears for the same boot, even though a
1208
+ // temp-keep keeps the workspaces in the marker for a FUTURE boot.
1209
+ let bootAnswered = false
1210
+
1211
+ /** When to run the deletion confirm/action, as an epoch-ms timestamp. */
1212
+ function deleteAtMs(settings) {
1213
+ return Date.now() + (settings.deleteMode === 'delayed' ? (settings.deleteDelay * 1000) : 0)
1214
+ }
1215
+
1216
+ // A fixed boot-relative deadline (epoch ms) so a delayed confirm doesn't
1217
+ // slide forever on every /pending poll. Captured once, at boot.
1218
+ const bootDeadline = deleteAtMs(readSettings())
1219
+
1220
+ async function runBootCleanup(settings) {
1221
+ const list = await readState()
1222
+ if (list.length === 0) return
1223
+ if (settings.confirmBeforeDelete) {
1224
+ // Hold the set for the browser to confirm. Keep the marker intact so a
1225
+ // user who never answers still has the workspaces next boot.
1226
+ bootAnswered = false
1227
+ for (const entry of list) if (entry?.workspaceId) pending.set(entry.workspaceId, entry)
1228
+ console.log(`[${PLUGIN_ID}] awaiting confirmation to delete ${list.length} temporary workspace(s)`)
1229
+ return
1230
+ }
1231
+ // No confirmation required: remove now or schedule the removal.
1232
+ if (settings.deleteMode === 'delayed' && settings.deleteDelay > 0) {
1233
+ const ms = settings.deleteDelay * 1000
1234
+ console.log(`[${PLUGIN_ID}] scheduling deletion of ${list.length} temporary workspace(s) in ${ms}ms`)
1235
+ const timer = setTimeout(() => {
1236
+ void cleanupTempWorkspaces(ctx)
1237
+ .then((removed) => { if (removed > 0) console.log(`[${PLUGIN_ID}] removed ${removed} temporary workspace(s) after delay`) })
1238
+ .catch((error) => console.error(`[${PLUGIN_ID}] delayed boot cleanup failed`, error))
1239
+ }, ms)
1240
+ timer.unref?.()
1241
+ return
1242
+ }
1243
+ const removed = await cleanupTempWorkspaces(ctx)
1244
+ if (removed > 0) console.log(`[${PLUGIN_ID}] removed ${removed} temporary workspace(s) on boot`)
1245
+ }
1246
+
1247
+ /** The marker entries currently held for confirmation, with a marker fallback. */
1248
+ async function heldEntries() {
1249
+ if (pending.size > 0) return [...pending.values()]
1250
+ // The in-memory cache may not be populated yet (boot cleanup is async, and
1251
+ // the browser can poll before it settles). Fall back to the durable marker
1252
+ // so a confirm decision never silently loses workspaces.
1253
+ const settings = readSettings()
1254
+ if (!settings.confirmBeforeDelete) return []
1255
+ return (await readState()).filter((entry) => entry?.workspaceId)
1256
+ }
1257
+
1258
+ void runBootCleanup(readSettings())
1259
+ .catch((error) => console.error(`[${PLUGIN_ID}] boot cleanup failed`, error))
1260
+
1261
+ // Re-attach migrated sessions to their new permanent workspace. This runs at
1262
+ // boot, after the workspace registry initialized and indexed the migrated
1263
+ // headers with the new cwd — so `attachSession` validates against the new path
1264
+ // and succeeds. Without this, an already-initialized registry never groups the
1265
+ // migrated sessions and they end up ungrouped.
1266
+ void reattachPendingSessions(ctx)
1267
+ .then((n) => { if (n > 0) console.log(`[${PLUGIN_ID}] re-attached ${n} migrated session(s) after restart`) })
1268
+ .catch((error) => console.error(`[${PLUGIN_ID}] pending re-attach failed`, error))
1269
+
1270
+ // Reap leftover temp-workspace session project dirs from prior create/delete
1271
+ // cycles (the store never pruned the empty `--<cwd>--` parent). Runs alone so
1272
+ // it also fixes dirs orphaned before this version.
1273
+ void cleanupOrphanTempProjectDirs(ctx)
1274
+ .then((removed) => { if (removed > 0) console.log(`[${PLUGIN_ID}] swept ${removed} orphaned temp-workspace session dir(s)`) })
1275
+ .catch((error) => console.error(`[${PLUGIN_ID}] orphaned-dir sweep failed`, error))
1276
+
1277
+ // Reclaim the durable metadata residue of already-deleted temp workspaces:
1278
+ // projection-cache rows, archived-id entries, and legacy per-session cache
1279
+ // files. Identity-checked reads never surface them as conversations, but
1280
+ // "no trace" means no trace — this is the authoritative net behind the
1281
+ // delete-time pruning in `removeLiveSessions`. The projection cache may
1282
+ // still be initializing when `apply` runs (this plugin does not inject it),
1283
+ // so retry until its table is ready.
1284
+ const sweepResidue = (attempt) => {
1285
+ const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
1286
+ if (!cache || !cache.table) {
1287
+ if (attempt < 10) setTimeout(() => sweepResidue(attempt + 1), 2000)
1288
+ return
1289
+ }
1290
+ void pruneOrphanTempResidue(ctx)
1291
+ .catch((error) => console.error(`[${PLUGIN_ID}] orphaned projection-cache sweep failed`, error))
1292
+ }
1293
+ sweepResidue(0)
1294
+
1295
+ // ── dispatch ─────────────────────────────────────────────────────────────
1296
+ const dispatch = {
1297
+ create: async () => createTempWorkspace(ctx),
1298
+ list: async () => ({ entries: await listTempWorkspaces() }),
1299
+ delete: async (payload) => {
1300
+ if (typeof payload?.workspaceId !== 'string' || payload.workspaceId === '') {
1301
+ throw new TempWorkspaceError('bad-arg', 'missing string "workspaceId"', 400)
1302
+ }
1303
+ // Use the marker entry (which carries the durable path) so a workspace
1304
+ // whose registry record is already gone still reaps its folder.
1305
+ const entry = (await readState()).find((item) => item.workspaceId === payload.workspaceId)
1306
+ await deleteTempWorkspace(ctx, entry ?? { workspaceId: payload.workspaceId, path: undefined })
1307
+ return { ok: true }
1308
+ },
1309
+ // Settings read/write for the Settings -> Plugins card (mirrors the
1310
+ // workspace-auto-approval /config contract).
1311
+ config: async (payload) => {
1312
+ const settings = readSettings()
1313
+ if (payload && typeof payload === 'object' && !Array.isArray(payload) && Object.keys(payload).length > 0) {
1314
+ await writeSettings(payload)
1315
+ return readSettings()
1316
+ }
1317
+ return settings
1318
+ },
1319
+ // Restore every settings field to its default (empties the user layer).
1320
+ configReset: async () => {
1321
+ await writeSettings(null)
1322
+ return readSettings()
1323
+ },
1324
+ // Pending-confirmation state for the boot popup.
1325
+ pending: async () => {
1326
+ const settings = readSettings()
1327
+ if (bootAnswered) return { pending: [], confirmBeforeDelete: settings.confirmBeforeDelete, deleteMode: settings.deleteMode, deleteAt: bootDeadline, pendingCount: 0 }
1328
+ const entries = await heldEntries()
1329
+ return {
1330
+ pending: entries.map((entry) => ({ workspaceId: entry.workspaceId, path: entry.path, createdAt: entry.createdAt })),
1331
+ confirmBeforeDelete: settings.confirmBeforeDelete,
1332
+ deleteMode: settings.deleteMode,
1333
+ deleteAt: bootDeadline,
1334
+ pendingCount: entries.length,
1335
+ }
1336
+ },
1337
+ // User confirmed: delete the held set now.
1338
+ confirm: async () => {
1339
+ bootAnswered = true
1340
+ const entries = await heldEntries()
1341
+ pending.clear()
1342
+ for (const entry of entries) {
1343
+ try { await deleteTempWorkspace(ctx, entry) } catch (error) { console.error(`[${PLUGIN_ID}] confirmed delete failed for ${entry?.workspaceId}`, error) }
1344
+ }
1345
+ return { deleted: entries.length }
1346
+ },
1347
+ // User chose "temporary keep": preserve these workspaces but keep them
1348
+ // temporary (they stay in the marker and are subject to cleanup again on a
1349
+ // later boot). Only the in-memory hold is released.
1350
+ keep: async () => {
1351
+ bootAnswered = true
1352
+ const entries = await heldEntries()
1353
+ pending.clear()
1354
+ return { kept: entries.length }
1355
+ },
1356
+ // Permanent keep: use the user-chosen folder directly as the permanent
1357
+ // workspace (move the temp files in, name it after the folder). With an
1358
+ // explicit workspaceId (from the per-row button) only that temp workspace is
1359
+ // moved; otherwise every held temp workspace is moved.
1360
+ permanentKeep: async (payload) => {
1361
+ const target = typeof payload?.target === 'string' && payload.target !== '' ? payload.target : undefined
1362
+ if (target === undefined) throw new TempWorkspaceError('bad-arg', 'missing string "target"', 400)
1363
+ const explicitId = typeof payload?.workspaceId === 'string' && payload.workspaceId !== '' ? payload.workspaceId : undefined
1364
+ let entries
1365
+ if (explicitId !== undefined) {
1366
+ bootAnswered = true
1367
+ const marker = await readState()
1368
+ const entry = marker.find((item) => item?.workspaceId === explicitId)
1369
+ entries = entry === undefined ? [] : [entry]
1370
+ } else {
1371
+ bootAnswered = true
1372
+ entries = await heldEntries()
1373
+ }
1374
+ if (entries.length === 0) return { moved: [] }
1375
+ const moved = []
1376
+ for (const entry of entries) {
1377
+ try {
1378
+ moved.push(await moveTempWorkspace(ctx, entry, target))
1379
+ } catch (error) {
1380
+ console.error(`[${PLUGIN_ID}] permanent-keep move failed for ${entry?.workspaceId}`, error)
1381
+ throw error
1382
+ }
1383
+ }
1384
+ return { moved }
1385
+ },
1386
+ }
1387
+
1388
+ async function writeSettings(patch) {
1389
+ if (settingsScope === undefined) return
1390
+ // `null` (or an empty patch) resets the whole user layer back to defaults.
1391
+ if (patch === null) {
1392
+ await settingsScope.replace({})
1393
+ return
1394
+ }
1395
+ // Normalize a partial patch over the defaulted current value, validating
1396
+ // deleteMode against its literal union and coercing numerics.
1397
+ const next = { ...readSettings(), ...(patch ?? {}) }
1398
+ if (next.deleteMode !== 'immediate' && next.deleteMode !== 'delayed') {
1399
+ throw new TempWorkspaceError('bad-arg', 'deleteMode must be "immediate" or "delayed"', 400)
1400
+ }
1401
+ const delay = Number(next.deleteDelay)
1402
+ if (!Number.isFinite(delay) || delay < 0) {
1403
+ throw new TempWorkspaceError('bad-arg', 'deleteDelay must be a non-negative number', 400)
1404
+ }
1405
+ await settingsScope.update({
1406
+ deleteMode: next.deleteMode,
1407
+ deleteDelay: Math.floor(delay),
1408
+ confirmBeforeDelete: Boolean(next.confirmBeforeDelete),
1409
+ })
1410
+ }
1411
+
1412
+ ctx.effect(() => ctx.webServer.register({
1413
+ kind: 'prefix',
1414
+ path: '/temp-workspace/api',
1415
+ handler: async (req, res) => {
1416
+ if (!fence(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } }); return }
1417
+ // Reads (config GET, pending GET) use GET; mutations use POST.
1418
+ const isRead = req.method === 'GET'
1419
+ if (!isRead && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }); return }
1420
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
1421
+ const method = pathname.startsWith('/temp-workspace/api/') ? pathname.slice('/temp-workspace/api/'.length) : undefined
1422
+ if (method === undefined || method.includes('/')) { writeError(res, new TempWorkspaceError('not-found', 'unknown temp-workspace method', 404)); return }
1423
+
1424
+ // Restart needs the raw request (port, socket, Origin), so handle it
1425
+ // before the payload-dispatch path. Same guard as dsh-market: direct
1426
+ // same-origin loopback, no forwarding headers.
1427
+ if (method === 'restart') {
1428
+ if (!trustedRestartRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'restart requires a same-origin loopback request' } }); return }
1429
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }); return }
1430
+ const port = servingPort(req)
1431
+ const result = await scheduleRestart(port)
1432
+ writeOk(res, { restarting: true, ...result })
1433
+ return
1434
+ }
1435
+
1436
+ try {
1437
+ const payload = isRead ? {} : await readJsonBody(req)
1438
+ const handler = dispatch[method]
1439
+ if (handler === undefined) throw new TempWorkspaceError('not-found', `unknown temp-workspace method "${method}"`, 404)
1440
+ writeOk(res, await handler(payload))
1441
+ } catch (error) {
1442
+ writeError(res, error)
1443
+ }
1444
+ },
1445
+ }), `${PLUGIN_ID}: /temp-workspace/api routes`)
1446
+ }