dsh-scratch-workspace 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/gc.js +9 -6
  2. package/lib/patch.js +129 -183
  3. package/package.json +1 -1
package/lib/gc.js CHANGED
@@ -127,9 +127,10 @@ export async function cleanOrphans({
127
127
  const info = await stat(dirPath)
128
128
  const ageMs = now - info.mtimeMs
129
129
 
130
- // Case 1: Empty abandoned session folder past grace period
130
+ // Case 1: Empty abandoned session folder
131
131
  const empty = await isDirectoryEmpty(dirPath)
132
- if (empty && ageMs >= emptyGraceMs) {
132
+ const hasSession = knownShortIds.has(folderShort)
133
+ if (empty && (!hasSession || ageMs >= emptyGraceMs)) {
133
134
  await removeSessionDir(dirPath)
134
135
  cleaned.push({ path: dirPath, reason: 'empty-abandoned' })
135
136
  logger?.info?.(`dsh-scratch-workspace gc: removed empty session dir ${dirPath}`)
@@ -137,7 +138,6 @@ export async function cleanOrphans({
137
138
  }
138
139
 
139
140
  // Case 2: Orphan session directory no longer associated with any session
140
- const hasSession = knownShortIds.has(folderShort)
141
141
  if (!hasSession && ageMs > orphanTtlMs) {
142
142
  await removeSessionDir(dirPath)
143
143
  cleaned.push({ path: dirPath, reason: 'orphan-ttl' })
@@ -172,19 +172,22 @@ export function setupAutoGc(ctx, iso, {
172
172
  // Live sessions from registry
173
173
  const registry = ctx.root?.workspaceRegistry ?? ctx.workspaceRegistry
174
174
  const workspaces = registry?.list?.() ?? []
175
+ const archived = new Set(registry?.archivedSessionIds ?? [])
175
176
  for (const ws of workspaces) {
176
- for (const id of ws.sessionIds ?? []) ids.add(id)
177
+ for (const id of ws.sessionIds ?? []) {
178
+ if (!archived.has(id)) ids.add(id)
179
+ }
177
180
  }
178
181
  // Live agents
179
182
  const agents = ctx.agents ?? ctx.root?.agents
180
183
  if (typeof agents?.list === 'function') {
181
- for (const ag of agents.list() ?? []) if (ag?.id) ids.add(ag.id)
184
+ for (const ag of agents.list() ?? []) if (ag?.id && !archived.has(ag.id)) ids.add(ag.id)
182
185
  }
183
186
  // Stored headers if available
184
187
  try {
185
188
  const stored = await registry?.listStoredHeaders?.()
186
189
  if (Array.isArray(stored)) {
187
- for (const h of stored) if (h?.id) ids.add(h.id)
190
+ for (const h of stored) if (h?.id && !archived.has(h.id)) ids.add(h.id)
188
191
  }
189
192
  } catch {}
190
193
 
package/lib/patch.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  allocateSessionDir,
16
16
  isMeaningfulTitle,
17
17
  sanitizeTitleForPath,
18
+ SESSION_DIR_PATTERN,
18
19
  SESSION_DIR_PREFIX,
19
20
  setIsolated,
20
21
  } from './isolate.js'
@@ -23,6 +24,51 @@ import { cleanOrphans, isDirectoryEmpty, removeSessionDir, shortSessionId } from
23
24
  /** The registry-internal host property. */
24
25
  const HOST_PROPERTY = 'host'
25
26
 
27
+ /**
28
+ * Locate and remove a session's directory if it is empty.
29
+ * Uses 3-tier lookup: in-memory iso state -> registry index -> isolating roots scan by shortId.
30
+ * @param {string} sessionId
31
+ * @param {object} iso
32
+ * @param {object} [registry]
33
+ */
34
+ export async function cleanEmptySessionDir(sessionId, iso, registry) {
35
+ if (!sessionId) return
36
+ let sessionDir = iso?.sessionPath?.(sessionId)
37
+ if (!sessionDir && typeof registry?.sessionPath === 'function') {
38
+ try { sessionDir = registry.sessionPath(sessionId) } catch {}
39
+ }
40
+ const short = shortSessionId(sessionId)
41
+ const candidateDirs = new Set()
42
+ if (sessionDir) candidateDirs.add(sessionDir)
43
+
44
+ if (short && typeof iso?.roots === 'function') {
45
+ for (const root of iso.roots()) {
46
+ try {
47
+ const entries = await readdir(root, { withFileTypes: true })
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue
50
+ const match = entry.name.match(SESSION_DIR_PATTERN)
51
+ if (match) {
52
+ const entryShort = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
53
+ if (entryShort === short) {
54
+ candidateDirs.add(join(root, entry.name))
55
+ }
56
+ }
57
+ }
58
+ } catch {}
59
+ }
60
+ }
61
+
62
+ iso?.forgetSession?.(sessionId)
63
+
64
+ for (const dir of candidateDirs) {
65
+ const empty = await isDirectoryEmpty(dir).catch(() => false)
66
+ if (empty) {
67
+ await removeSessionDir(dir).catch(() => {})
68
+ }
69
+ }
70
+ }
71
+
26
72
  /**
27
73
  * Wrap the Workspace registry's session-path index so isolated sessions resolve
28
74
  * to the Workspace that owns their directory across all DSH versions.
@@ -81,29 +127,30 @@ export function patchRegistry(registry, iso) {
81
127
  }
82
128
 
83
129
  // Hook archiveSession to immediately clean empty session directories on archive
84
- const originalArchive = registry?.archiveSession
130
+ const proto = registry ? Object.getPrototypeOf(registry) : null
131
+ const originalArchive = proto?.archiveSession ?? registry?.archiveSession
85
132
  if (typeof originalArchive === 'function') {
86
- registry.archiveSession = async function archiveSession(sessionId, options) {
133
+ const wrappedArchive = async function archiveSession(sessionId, options) {
87
134
  const res = await originalArchive.call(this, sessionId, options)
88
- if (sessionId) {
89
- const sessionDir = iso.sessionPath(sessionId)
90
- if (sessionDir) {
91
- const empty = await isDirectoryEmpty(sessionDir).catch(() => false)
92
- if (empty) {
93
- iso.forgetSession(sessionId)
94
- await removeSessionDir(sessionDir).catch(() => {})
95
- }
96
- }
97
- }
135
+ await cleanEmptySessionDir(sessionId, iso, this).catch(() => {})
98
136
  return res
99
137
  }
138
+ registry.archiveSession = wrappedArchive
139
+ if (proto && typeof proto.archiveSession === 'function') {
140
+ proto.archiveSession = wrappedArchive
141
+ }
100
142
  }
101
143
 
102
144
  return () => {
103
145
  if (typeof originalMatches === 'function') target.candidateMatches = originalMatches
104
146
  if (typeof originalRemember === 'function') target.rememberSessionPath = originalRemember
105
147
  if (typeof originalPath === 'function') target.sessionPath = originalPath
106
- if (typeof originalArchive === 'function') registry.archiveSession = originalArchive
148
+ if (typeof originalArchive === 'function') {
149
+ registry.archiveSession = originalArchive
150
+ if (proto && typeof proto.archiveSession === 'function') {
151
+ proto.archiveSession = originalArchive
152
+ }
153
+ }
107
154
  }
108
155
  }
109
156
 
@@ -180,32 +227,25 @@ export function patchSessionCreate(ctx, iso) {
180
227
 
181
228
  if (owner === undefined) return await originalCreate.call(this, request0)
182
229
 
183
- // Check if request0.sessionId is an existing blank session.
184
- // In an isolating workspace, if request0.sessionId already has a recorded cwd in DSH,
185
- // or is already tracked in isolation, drop it so a fresh unique session is minted,
186
- // avoiding ApiSessionCwdConflict and duplicate directory naming.
230
+ // Check if request0.sessionId is an existing blank session minted under parent workspace cwd.
231
+ // If so, it cannot be adopted for sandbox cwd; drop it and mint a fresh ID.
187
232
  let targetSessionId = request0.sessionId
188
233
  if (targetSessionId !== undefined) {
189
- const trackedPath = typeof iso?.sessionPath === 'function' ? iso.sessionPath(targetSessionId) : undefined
190
- if (trackedPath !== undefined) {
234
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
235
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents', false) : ctx.agents
236
+ const live = sessions?.get?.(targetSessionId) ?? agents?.get?.(targetSessionId)
237
+ const existingCwd = live?.session?.header?.cwd
238
+ if (existingCwd !== undefined && existingCwd === owner) {
191
239
  targetSessionId = undefined
192
240
  } else {
193
- const sessions = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
194
- const agents = typeof ctx.get === 'function' ? ctx.get('agents', false) : ctx.agents
195
- const live = sessions?.get?.(targetSessionId) ?? agents?.get?.(targetSessionId)
196
- const existingCwd = live?.session?.header?.cwd
197
- if (existingCwd !== undefined) {
198
- targetSessionId = undefined
199
- } else {
200
- const query = typeof ctx.get === 'function' ? ctx.get('sessionQuery', false) : ctx.sessionQuery
201
- if (typeof query?.observeSession === 'function') {
202
- try {
203
- const obs = await query.observeSession(targetSessionId)
204
- if (obs?.header?.cwd !== undefined) {
205
- targetSessionId = undefined
206
- }
207
- } catch {}
208
- }
241
+ const query = typeof ctx.get === 'function' ? ctx.get('sessionQuery', false) : ctx.sessionQuery
242
+ if (typeof query?.observeSession === 'function') {
243
+ try {
244
+ const obs = await query.observeSession(targetSessionId)
245
+ if (obs?.header?.cwd !== undefined && obs.header.cwd === owner) {
246
+ targetSessionId = undefined
247
+ }
248
+ } catch {}
209
249
  }
210
250
  }
211
251
  }
@@ -245,7 +285,6 @@ export function patchSessionCreate(ctx, iso) {
245
285
  if (!currentBase.includes(actualShort)) {
246
286
  const correctDir = resolve(join(owner, `${SESSION_DIR_PREFIX}${actualShort}`))
247
287
  await rename(fallbackCwd, correctDir).catch(() => {})
248
- iso.recordRename?.(fallbackCwd, correctDir)
249
288
  iso.recordSessionPath(created.sessionId, correctDir)
250
289
  return created
251
290
  }
@@ -266,7 +305,6 @@ export function patchSessionCreate(ctx, iso) {
266
305
  const correctDir = resolve(join(owner, `${SESSION_DIR_PREFIX}${actualShort}`))
267
306
  try {
268
307
  await rename(cwd, correctDir)
269
- iso.recordRename?.(cwd, correctDir)
270
308
  cwd = correctDir
271
309
  } catch {}
272
310
  }
@@ -299,60 +337,40 @@ export function patchSessionCreate(ctx, iso) {
299
337
  if (typeof originalDelete === 'function') {
300
338
  controller().delete = async function del(req) {
301
339
  const sessionId = req?.sessionId ?? (typeof req === 'string' ? req : undefined)
302
- const sessionDir = sessionId ? iso.sessionPath(sessionId) : undefined
303
340
  try {
304
341
  return await originalDelete.call(this, req)
305
342
  } finally {
306
- if (sessionDir) {
307
- iso.forgetSession(sessionId)
308
- await removeSessionDir(sessionDir)
343
+ if (sessionId) {
344
+ await cleanEmptySessionDir(sessionId, iso, registry())
309
345
  }
310
346
  }
311
347
  }
312
348
  }
313
349
 
350
+ // Hook workspaceController.archiveSession for frontend archive actions
351
+ const wsCtrl = ctx.root?.workspaceController ?? ctx.workspaceController
352
+ const originalWsArchive = wsCtrl?.archiveSession
353
+ if (typeof originalWsArchive === 'function') {
354
+ wsCtrl.archiveSession = async function archiveSession(request) {
355
+ const res = await originalWsArchive.call(this, request)
356
+ if (request?.sessionId) {
357
+ await cleanEmptySessionDir(request.sessionId, iso, registry())
358
+ }
359
+ return res
360
+ }
361
+ }
362
+
314
363
  return () => {
315
364
  controller().create = originalCreate
316
365
  if (typeof originalRename === 'function') controller().rename = originalRename
317
366
  if (typeof originalDelete === 'function') controller().delete = originalDelete
367
+ if (typeof originalWsArchive === 'function') wsCtrl.archiveSession = originalWsArchive
318
368
  }
319
369
  }
320
370
 
321
371
  /**
322
- * Helper to patch a method across an object and its entire prototype chain
323
- * up to Object.prototype, ensuring every Cordis service proxy, derived instance,
324
- * and caller shares the patched behavior.
325
- * @param {object} obj - target instance or service
326
- * @param {string} methodName - method to wrap
327
- * @param {Function} wrapper - function taking original method and returning new method
328
- * @returns {Function} disposer restoring all patched methods
329
- */
330
- function patchMethod(obj, methodName, wrapper) {
331
- if (!obj) return () => {}
332
- const targets = []
333
- let curr = obj
334
- while (curr && curr !== Object.prototype) {
335
- if (Object.prototype.hasOwnProperty.call(curr, methodName) || typeof curr[methodName] === 'function') {
336
- targets.push({ target: curr, original: curr[methodName] })
337
- }
338
- curr = Object.getPrototypeOf(curr)
339
- }
340
- if (!targets.some(t => t.target === obj) && typeof obj[methodName] === 'function') {
341
- targets.push({ target: obj, original: obj[methodName] })
342
- }
343
- for (const item of targets) {
344
- item.target[methodName] = wrapper(item.original)
345
- }
346
- return () => {
347
- for (const item of targets) {
348
- item.target[methodName] = item.original
349
- }
350
- }
351
- }
352
-
353
- /**
354
- * Wrap ctx.fs (resolve, stat, lstat) so tool operations (write, edit, read, etc.) automatically
355
- * translate pre-rename directory paths to their new renamed directory paths across all contexts.
372
+ * Wrap ctx.fs.resolve so tool operations (write, edit, read, etc.) automatically
373
+ * translate pre-rename directory paths to their new renamed directory paths.
356
374
  * @param ctx - Cordis context
357
375
  * @param iso - shared isolation state
358
376
  * @returns disposer
@@ -369,68 +387,25 @@ export function patchFs(ctx, iso) {
369
387
  }
370
388
 
371
389
  const fs = getFs()
372
- if (!fs) return () => {}
373
-
374
- const disposers = []
375
-
376
- // Wrap fs.resolve
377
- disposers.push(patchMethod(fs, 'resolve', (originalResolve) => {
378
- if (typeof originalResolve !== 'function') return originalResolve
379
- return async function resolve(path, options) {
380
- const targetPath = typeof path === 'string' ? (iso.rewritePath?.(path) ?? path) : path
381
- let targetOpts = options
382
- if (options?.cwd && typeof options.cwd === 'string') {
383
- const rewrittenCwd = iso.rewritePath?.(options.cwd) ?? options.cwd
384
- if (rewrittenCwd !== options.cwd) {
385
- targetOpts = { ...options, cwd: rewrittenCwd }
386
- }
387
- }
388
- return await originalResolve.call(this, targetPath, targetOpts)
389
- }
390
- }))
391
-
392
- // Wrap fs.stat
393
- disposers.push(patchMethod(fs, 'stat', (originalStat) => {
394
- if (typeof originalStat !== 'function') return originalStat
395
- return async function stat(target, signal) {
396
- let effectiveTarget = target
397
- if (target && typeof target === 'object' && typeof target.targetKey === 'string') {
398
- const rewritten = iso.rewritePath?.(target.targetKey)
399
- if (rewritten && rewritten !== target.targetKey) {
400
- effectiveTarget = { ...target, targetKey: rewritten }
401
- }
402
- } else if (typeof target === 'string') {
403
- effectiveTarget = iso.rewritePath?.(target) ?? target
404
- }
405
- return await originalStat.call(this, effectiveTarget, signal)
406
- }
407
- }))
408
-
409
- // Wrap fs.lstat
410
- disposers.push(patchMethod(fs, 'lstat', (originalLstat) => {
411
- if (typeof originalLstat !== 'function') return originalLstat
412
- return async function lstat(path, opts, signal) {
413
- const targetPath = typeof path === 'string' ? (iso.rewritePath?.(path) ?? path) : path
414
- let targetOpts = opts
415
- if (opts?.cwd && typeof opts.cwd === 'string') {
416
- const rewrittenCwd = iso.rewritePath?.(opts.cwd) ?? opts.cwd
417
- if (rewrittenCwd !== opts.cwd) {
418
- targetOpts = { ...opts, cwd: rewrittenCwd }
419
- }
420
- }
421
- return await originalLstat.call(this, targetPath, targetOpts, signal)
390
+ if (!fs || typeof fs.resolve !== 'function') return () => {}
391
+
392
+ const originalResolve = fs.resolve
393
+ fs.resolve = async function resolve(path, options) {
394
+ let target = path
395
+ if (typeof path === 'string') {
396
+ target = iso.rewritePath?.(path) ?? path
422
397
  }
423
- }))
398
+ return await originalResolve.call(this, target, options)
399
+ }
424
400
 
425
401
  return () => {
426
- for (const dispose of disposers) dispose()
402
+ fs.resolve = originalResolve
427
403
  }
428
404
  }
429
405
 
430
406
  /**
431
407
  * Wrap workingDirectory service so all tool executions (write, edit, bash, etc.)
432
408
  * immediately track renamed session working directories without throwing ENOENT.
433
- * Patches get, ensure, and directory across instance and prototype hierarchy.
434
409
  * @param ctx - Cordis context
435
410
  * @param iso - shared isolation state
436
411
  * @returns disposer
@@ -449,76 +424,48 @@ export function patchWorkingDirectory(ctx, iso) {
449
424
  const wd = getWd()
450
425
  if (!wd) return () => {}
451
426
 
452
- const disposers = []
427
+ const originalGet = wd.get
428
+ const originalEnsure = wd.ensure
453
429
 
454
- // Wrap wd.get
455
- disposers.push(patchMethod(wd, 'get', (originalGet) => {
456
- if (typeof originalGet !== 'function') return originalGet
457
- return function get(session) {
430
+ if (typeof originalGet === 'function') {
431
+ wd.get = function get(session) {
458
432
  const sessionId = session?.id
459
433
  if (sessionId) {
460
434
  const isolated = iso.sessionPath(sessionId)
461
435
  if (isolated !== undefined) {
462
- if (session?.header) session.header.cwd = isolated
463
436
  return isolated
464
437
  }
465
438
  }
466
- if (session?.header?.cwd) {
467
- const rewritten = iso.rewritePath?.(session.header.cwd)
468
- if (rewritten && rewritten !== session.header.cwd) {
469
- session.header.cwd = rewritten
470
- return rewritten
471
- }
472
- }
473
- const raw = originalGet.call(this, session)
474
- return typeof raw === 'string' ? (iso.rewritePath?.(raw) ?? raw) : raw
439
+ return originalGet.call(this, session)
475
440
  }
476
- }))
477
-
478
- // Wrap wd.ensure
479
- disposers.push(patchMethod(wd, 'ensure', (originalEnsure) => {
480
- if (typeof originalEnsure !== 'function') return originalEnsure
481
- return async function ensure(agent, signal) {
482
- const sessionId = agent?.session?.id ?? agent?.id
483
- let isolated = sessionId ? iso.sessionPath(sessionId) : undefined
484
- if (!isolated && agent?.session?.header?.cwd) {
485
- const rewritten = iso.rewritePath?.(agent.session.header.cwd)
486
- if (rewritten && rewritten !== agent.session.header.cwd) {
487
- isolated = rewritten
488
- }
489
- }
490
- if (isolated !== undefined) {
491
- const session = agent?.session
492
- if (session) {
493
- if (session.header) session.header.cwd = isolated
494
- if (typeof session.append === 'function') {
495
- try {
496
- const handle = this.workingDirectoryProjectionHandle
441
+ }
442
+
443
+ if (typeof originalEnsure === 'function') {
444
+ wd.ensure = async function ensure(agent, signal) {
445
+ const sessionId = agent?.session?.id
446
+ if (sessionId) {
447
+ const isolated = iso.sessionPath(sessionId)
448
+ if (isolated !== undefined) {
449
+ const session = agent?.session
450
+ if (session) {
451
+ if (typeof session.append === 'function') {
452
+ const handle = wd.workingDirectoryProjectionHandle
497
453
  const currentProj = handle?.stateOf?.(session)
498
454
  if (currentProj !== isolated) {
499
455
  session.append('working-directory/change', { cwd: isolated })
500
456
  }
501
- } catch {}
457
+ }
502
458
  }
459
+ return isolated
503
460
  }
504
- return isolated
505
461
  }
506
462
  return await originalEnsure.call(this, agent, signal)
507
463
  }
508
- }))
509
-
510
- // Wrap wd.directory
511
- disposers.push(patchMethod(wd, 'directory', (originalDirectory) => {
512
- if (typeof originalDirectory !== 'function') return originalDirectory
513
- return async function directory(path, cwd, signal) {
514
- const targetPath = typeof path === 'string' ? (iso.rewritePath?.(path) ?? path) : path
515
- const targetCwd = typeof cwd === 'string' ? (iso.rewritePath?.(cwd) ?? cwd) : cwd
516
- return await originalDirectory.call(this, targetPath, targetCwd, signal)
517
- }
518
- }))
464
+ }
519
465
 
520
466
  return () => {
521
- for (const dispose of disposers) dispose()
467
+ if (typeof originalGet === 'function') wd.get = originalGet
468
+ if (typeof originalEnsure === 'function') wd.ensure = originalEnsure
522
469
  }
523
470
  }
524
471
 
@@ -581,14 +528,10 @@ export async function renameSessionDirToTitle(sessionId, title, iso, registry, c
581
528
  liveAgent?.session
582
529
 
583
530
  if (targetSession) {
584
- if (targetSession.header) targetSession.header.cwd = targetDir
585
531
  if (typeof targetSession.append === 'function') {
586
532
  targetSession.append('working-directory/change', { cwd: targetDir })
587
533
  }
588
534
  }
589
- if (liveAgent?.session?.header) {
590
- liveAgent.session.header.cwd = targetDir
591
- }
592
535
 
593
536
  // 6. Post-rename cleanup guard: If currentDir is ever re-created by an in-flight tool call,
594
537
  // merge files to targetDir and delete currentDir!
@@ -646,8 +589,11 @@ export function isolatedSessionsApi(ctx, iso) {
646
589
  async cleanOrphans(options = {}) {
647
590
  const registryInstance = registry()
648
591
  const known = new Set()
592
+ const archived = new Set(registryInstance?.archivedSessionIds ?? [])
649
593
  for (const ws of registryInstance?.list?.() ?? []) {
650
- for (const id of ws.sessionIds ?? []) known.add(id)
594
+ for (const id of ws.sessionIds ?? []) {
595
+ if (!archived.has(id)) known.add(id)
596
+ }
651
597
  }
652
598
  return await cleanOrphans({
653
599
  roots: iso.roots(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scratch-workspace",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 瞬态会话隔离与草稿清理插件 — 顺应官方原生工作区规范的会话沙箱隔离、侧边栏归集与磁盘生命周期自动清理",
5
5
  "author": "Mi Tom <6468993+MDX-Tom@users.noreply.github.com>",
6
6
  "license": "MIT",