dsh-session-mng 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/service.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { SessionMngError, messageOf } from './errors.js'
14
14
  import { normalizeTitle, proposeTitle, textOfEvent } from './title.js'
15
- import { buildState } from './state.js'
15
+ import { buildState, classifySession } from './state.js'
16
16
  import {
17
17
  defaultSessionsRoot,
18
18
  dirSize,
@@ -134,12 +134,30 @@ function normalizeIds(value, max, label = 'sessionIds') {
134
134
  return ids
135
135
  }
136
136
 
137
- /** Walk a lineage tree and collect every descendant session id. */
138
- function collectDescendants(node, out) {
137
+ /**
138
+ * Walk a lineage tree, splitting descendants into the two kinds the plugin
139
+ * treats differently.
140
+ *
141
+ * - `subagents` — delegation sessions; they belong to the root and are
142
+ * cascaded with it.
143
+ * - `forks` — ordinary branches (including historical logs that still record
144
+ * a parent). They are independent user sessions, so the walk records one and
145
+ * stops: nothing below it may be cascaded with the root.
146
+ *
147
+ * @param {object} node - one `SessionLineageNode` returned by `traceSession`.
148
+ * @param {{ subagents: object[], forks: object[] }} out - accumulator.
149
+ */
150
+ function partitionDescendants(node, out) {
139
151
  for (const child of node?.descendants ?? []) {
140
- const id = child?.session?.header?.id
141
- if (typeof id === 'string') out.push(id)
142
- collectDescendants(child, out)
152
+ const record = child?.session
153
+ const id = record?.header?.id
154
+ if (typeof id !== 'string') continue
155
+ if (classifySession(record.header) === 'subagent') {
156
+ out.subagents.push(record)
157
+ partitionDescendants(child, out)
158
+ } else {
159
+ out.forks.push(record)
160
+ }
143
161
  }
144
162
  return out
145
163
  }
@@ -200,7 +218,7 @@ export function createService(ctx, config) {
200
218
  }
201
219
  try {
202
220
  const lineage = await trace.call(query(), id)
203
- return collectDescendants(lineage, [])
221
+ return partitionDescendants(lineage, { subagents: [], forks: [] })
204
222
  } catch (error) {
205
223
  throw new SessionMngError('trace-failed', `could not trace descendants of "${id}": ${messageOf(error)}`, 500)
206
224
  }
@@ -441,9 +459,16 @@ export function createService(ctx, config) {
441
459
  throw new SessionMngError('unsupported-backend', 'permanent deletion requires the local JSONL session backend', 501)
442
460
  }
443
461
  const archived = new Set(archivedIds())
462
+ const selected = new Set(ids)
444
463
  const roots = []
445
464
  const errors = []
446
465
  const sessions = []
466
+ // Ordinary forks are independent sessions: they keep their own log when a
467
+ // parent is deleted and only lose the "from <parent>" label. Collect them
468
+ // separately so the confirmation can say so instead of silently cascading
469
+ // a user branch. A fork selected in this same batch is deleted on its own.
470
+ const forkChildren = []
471
+ const forkSeen = new Set()
447
472
  for (const rootId of ids) {
448
473
  try {
449
474
  if (!archived.has(rootId)) {
@@ -454,8 +479,18 @@ export function createService(ctx, config) {
454
479
  const snapshot = await store.stat(rootId)
455
480
  const header = snapshot?.header
456
481
  if (header === undefined) throw new SessionMngError('not-found', `session "${rootId}" is not stored`, 404)
457
- const descendants = await descendantsOf(rootId)
458
- const all = [rootId, ...descendants.filter((id) => id !== rootId)]
482
+ const lineage = await descendantsOf(rootId)
483
+ const all = [rootId, ...lineage.subagents.map((record) => record.header.id).filter((id) => id !== rootId)]
484
+ for (const record of lineage.forks) {
485
+ const forkId = record.header.id
486
+ if (selected.has(forkId) || forkSeen.has(forkId)) continue
487
+ forkSeen.add(forkId)
488
+ forkChildren.push({
489
+ id: forkId,
490
+ parentId: record.header.parentSession ?? rootId,
491
+ title: await readTitle(forkId),
492
+ })
493
+ }
459
494
  const rootRow = {
460
495
  id: rootId,
461
496
  title: await readTitle(rootId),
@@ -508,6 +543,7 @@ export function createService(ctx, config) {
508
543
  errors,
509
544
  sessions,
510
545
  descendantCount: roots.reduce((sum, root) => sum + root.descendantCount, 0),
546
+ forkChildren,
511
547
  totalBytes: sessions.reduce((sum, row) => sum + row.bytes, 0),
512
548
  }
513
549
  }
@@ -535,14 +571,17 @@ export function createService(ctx, config) {
535
571
  })
536
572
  continue
537
573
  }
538
- let descendants
574
+ let lineage
539
575
  try {
540
- descendants = await descendantsOf(rootId)
576
+ lineage = await descendantsOf(rootId)
541
577
  } catch (error) {
542
578
  failures.set(rootId, { sessionId: rootId, ok: false, error: resultError(error) })
543
579
  continue
544
580
  }
545
- for (const id of [rootId, ...descendants]) {
581
+ // Only delegation sessions are cascaded. An ordinary fork (including a
582
+ // historical log that still records a parent) is an independent user
583
+ // session and stays where it is.
584
+ for (const id of [rootId, ...lineage.subagents.map((record) => record.header.id)]) {
546
585
  if (seen.has(id)) continue
547
586
  seen.add(id)
548
587
  const snapshot = await store.stat(id).catch(() => undefined)
@@ -732,13 +771,22 @@ export function createService(ctx, config) {
732
771
  if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
733
772
  throw new SessionMngError('session-running', 'this session is running; stop it before moving', 409)
734
773
  }
735
- if (header.origin === 'subagent' || header.parentSession !== undefined) {
774
+ if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
736
775
  throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
737
776
  }
738
- const descendants = await descendantsOf(id)
739
- if (descendants.length > 0) {
777
+ if (header.parentSession !== undefined && header.parentSession !== null) {
778
+ throw new SessionMngError(
779
+ 'branch-session',
780
+ "a branch session must stay in its parent's workspace, so it is not moved on its own",
781
+ 409,
782
+ )
783
+ }
784
+ // Only subagent descendants block a move. An ordinary fork (historical or
785
+ // not) is an independent session, so it no longer blocks its source.
786
+ const lineage = await descendantsOf(id)
787
+ if (lineage.subagents.length > 0) {
740
788
  throw new SessionMngError('has-descendants', 'this session has subagent descendants; they cannot follow it yet', 409, {
741
- descendants: descendants.length,
789
+ descendants: lineage.subagents.length,
742
790
  })
743
791
  }
744
792
  const source = workspaceOfId(id)
package/lib/state.js CHANGED
@@ -140,10 +140,31 @@ function headerOf(row, snapshotsById, ctx) {
140
140
  return snapshotsById.get(row.sessionId)?.header
141
141
  }
142
142
 
143
- /** Whether a header/summary pair describes a subagent session. */
144
- function isSubagent(header, summary) {
145
- if (header?.origin === 'subagent' || summary?.origin === 'subagent') return true
146
- return Boolean(header?.parentSession ?? summary?.parentSessionId)
143
+ /**
144
+ * Classify one session from whatever identifying fields are available.
145
+ *
146
+ * Callers pass a stored header, a list summary, or a merged view, so both
147
+ * field spellings are accepted (`parentSession` on a header, `parentSessionId`
148
+ * on a summary). Keeping this in one place is what makes the listing, the
149
+ * delete scope and the move guard agree on what an ordinary fork is: only
150
+ * delegation (`origin: 'subagent'` or a delegation depth) marks an internal
151
+ * session. A parent alone is not enough — an ordinary fork carries one too,
152
+ * and historical forks still record it.
153
+ */
154
+ export function classifySession(fields) {
155
+ if (fields?.origin === 'subagent') return 'subagent'
156
+ if (typeof fields?.delegationDepth === 'number' && fields.delegationDepth >= 1) return 'subagent'
157
+ const parent = fields?.parentSession ?? fields?.parentSessionId
158
+ return parent === undefined || parent === null || parent === '' ? 'root' : 'fork'
159
+ }
160
+
161
+ /** Classify one row of the listing from its header and summary. */
162
+ function classifyKind(header, summary) {
163
+ return classifySession({
164
+ origin: header?.origin ?? summary?.origin,
165
+ delegationDepth: header?.delegationDepth ?? summary?.delegationDepth,
166
+ parentSession: header?.parentSession ?? summary?.parentSessionId,
167
+ })
147
168
  }
148
169
 
149
170
  /**
@@ -201,6 +222,7 @@ export async function buildState(ctx, config, options = {}) {
201
222
  for (const id of snapshotById.keys()) knownIds.add(id)
202
223
  const rows = []
203
224
  const missingTitles = []
225
+ let hiddenSubagents = 0
204
226
 
205
227
  const pushRow = (base) => {
206
228
  const id = base.sessionId
@@ -208,8 +230,11 @@ export async function buildState(ctx, config, options = {}) {
208
230
  seen.add(id)
209
231
  const header = headerOf(base, snapshotById, ctx)
210
232
  const summary = base
211
- const subagent = isSubagent(header, summary)
212
- if (subagent && !includeSubagents) return
233
+ const kind = classifyKind(header, summary)
234
+ if (kind === 'subagent' && !includeSubagents) {
235
+ hiddenSubagents += 1
236
+ return
237
+ }
213
238
 
214
239
  const cwd = summary.cwd ?? header?.cwd ?? ''
215
240
  const workspace =
@@ -234,13 +259,15 @@ export async function buildState(ctx, config, options = {}) {
234
259
  running,
235
260
  blank: summary.blank === true,
236
261
  origin: header?.origin ?? summary.origin,
237
- parentSessionId: header?.parentSession ?? summary.parentSessionId,
262
+ kind,
263
+ isSubagent: kind === 'subagent',
264
+ isFork: kind === 'fork',
265
+ parentSessionId: header?.parentSession ?? summary.parentSessionId ?? null,
238
266
  agentPreset: header?.agentPreset ?? '',
239
267
  createdAt: header?.createdAt ?? summary.updatedAt ?? 0,
240
268
  updatedAt: summary.updatedAt ?? header?.createdAt ?? 0,
241
269
  sizeBytes: typeof snapshot?.sizeBytes === 'number' ? snapshot.sizeBytes : 0,
242
270
  formatStatus: summary.formatStatus ?? snapshot?.formatStatus,
243
- isSubagent: subagent,
244
271
  })
245
272
  }
246
273
 
@@ -268,6 +295,22 @@ export async function buildState(ctx, config, options = {}) {
268
295
  if (row.title === '') row.title = titles.get(row.id) ?? ''
269
296
  }
270
297
 
298
+ // A fork points at its parent by id only; resolving the title here lets the
299
+ // list show the relationship without a second round trip. `parentPresent`
300
+ // lets the client tell "untitled parent" apart from "the parent session is
301
+ // gone" (possible after the parent is permanently deleted).
302
+ const titlesById = new Map(rows.map((row) => [row.id, row.title]))
303
+ const forkCountByParent = new Map()
304
+ for (const row of rows) {
305
+ row.parentTitle = row.kind === 'fork' ? (titlesById.get(row.parentSessionId) ?? '') : ''
306
+ row.parentPresent = row.kind === 'fork' && titlesById.has(row.parentSessionId)
307
+ if (row.kind !== 'fork' || row.parentSessionId === null) continue
308
+ forkCountByParent.set(row.parentSessionId, (forkCountByParent.get(row.parentSessionId) ?? 0) + 1)
309
+ }
310
+ for (const row of rows) {
311
+ row.forkCount = forkCountByParent.get(row.id) ?? 0
312
+ }
313
+
271
314
  const jsonl =
272
315
  typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
273
316
  const capabilities = {
@@ -282,7 +325,7 @@ export async function buildState(ctx, config, options = {}) {
282
325
  title: capabilities.title,
283
326
  archive: !row.archived,
284
327
  restore: row.archived,
285
- move: capabilities.move && !row.live && !row.running && !row.isSubagent && row.parentSessionId === undefined,
328
+ move: capabilities.move && !row.live && !row.running && row.kind === 'root',
286
329
  delete: capabilities.delete && row.archived && !row.live,
287
330
  open: true,
288
331
  }
@@ -323,6 +366,8 @@ export async function buildState(ctx, config, options = {}) {
323
366
  active: rows.filter((row) => !row.archived).length,
324
367
  archived: rows.filter((row) => row.archived).length,
325
368
  subagents: rows.filter((row) => row.isSubagent).length,
369
+ hiddenSubagents,
370
+ forks: rows.filter((row) => row.isFork).length,
326
371
  bytes: totalBytes,
327
372
  },
328
373
  limits: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-mng",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Session management for DeepSeek Harness: one Settings page for every session — whole-session title generation, archive/restore, cross-workspace move, and permanent deletion of archived sessions.",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/ZilongYang/dsh-session-mng.git"
12
12
  },
13
- "homepage": "https://github.com/ZilongYang/dsh-session-mng#readme",
13
+ "homepage": "https://dsh.zlmix.com/session-mng/",
14
14
  "bugs": {
15
15
  "url": "https://github.com/ZilongYang/dsh-session-mng/issues"
16
16
  },
@@ -71,7 +71,8 @@
71
71
  "compatibility": {
72
72
  "dsh": ">=0.2.0-rc.1 <0.3.0-0",
73
73
  "dshReleases": {
74
- "0.2.1-rc.2": "compatible"
74
+ "0.2.1-rc.2": "compatible",
75
+ "0.2.1-rc.3": "compatible"
75
76
  }
76
77
  }
77
78
  }