dsh-code 0.8.0 → 0.9.1

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.
@@ -1,6 +1,7 @@
1
1
  /** Lightweight session-directory projection for the /resume picker. */
2
2
 
3
- import { basename, resolve } from 'node:path'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+ import { realpathSync } from 'node:fs'
4
5
  import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
5
6
 
6
7
  export interface SessionRecord {
@@ -42,6 +43,8 @@ export interface SessionDirectoryOptions {
42
43
  export interface SessionRow {
43
44
  readonly id: string
44
45
  readonly createdAt: number
46
+ /** Last-activity timestamp: artifact mtime when known, else createdAt. */
47
+ readonly updatedAt: number
45
48
  readonly cwd: string
46
49
  readonly workspace: string
47
50
  readonly parent?: string
@@ -63,7 +66,16 @@ export function isSubagentSession(header: SessionHeader): boolean {
63
66
 
64
67
  function comparablePath(value: string): string {
65
68
  const resolved = resolve(value)
66
- return CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved
69
+ // Codex's paths_match_after_normalization pattern: canonicalize through the
70
+ // filesystem when the path exists (resolving symlinks, subst drives, and
71
+ // junctions that plain `resolve` keeps distinct), falling back to lexical
72
+ // resolution when the directory no longer does.
73
+ const fold = (path: string): string => CASE_INSENSITIVE_FS ? path.toLowerCase() : path
74
+ try {
75
+ return fold(realpathSync(resolved))
76
+ } catch {
77
+ return fold(resolved)
78
+ }
67
79
  }
68
80
 
69
81
  /** Platform-consistent path equality for session cwd comparisons. */
@@ -98,8 +110,20 @@ export function newestRootForCwd(headers: readonly SessionHeader[], cwd: string)
98
110
  return local[0]
99
111
  }
100
112
 
101
- /** Filter/sort header-only records. No session log is loaded here. */
102
- export function projectSessionRows(records: readonly SessionRecord[], options: SessionDirectoryOptions): SessionRow[] {
113
+ /**
114
+ * Filter/sort header-only records. No session log is loaded here. Sorting is
115
+ * by LAST ACTIVITY (`updated` — artifact mtime when the caller resolved one,
116
+ * else createdAt), matching the codex resume picker's default UpdatedAt
117
+ * ordering: a session you kept talking in outranks one created later but idle.
118
+ * @param records - the header-only records.
119
+ * @param options - filter/sort options.
120
+ * @param updated - per-session last-activity timestamps, when resolved.
121
+ */
122
+ export function projectSessionRows(
123
+ records: readonly SessionRecord[],
124
+ options: SessionDirectoryOptions,
125
+ updated?: ReadonlyMap<string, number>,
126
+ ): SessionRow[] {
103
127
  const needle = options.query.trim().toLowerCase()
104
128
  return records
105
129
  .filter(record => options.sessions === 'all' || !isSubagentSession(record.header))
@@ -107,9 +131,13 @@ export function projectSessionRows(records: readonly SessionRecord[], options: S
107
131
  .map(record => {
108
132
  const cwd = record.header.cwd ?? ''
109
133
  const subagent = isSubagentSession(record.header)
134
+ const activity = updated?.get(record.header.id)
110
135
  return {
111
136
  id: record.header.id,
112
137
  createdAt: record.header.createdAt,
138
+ updatedAt: activity === undefined || !Number.isFinite(activity) || activity < record.header.createdAt
139
+ ? record.header.createdAt
140
+ : activity,
113
141
  cwd,
114
142
  workspace: cwd === '' ? '(no workspace)' : basename(cwd),
115
143
  parent: record.header.parentSession,
@@ -122,8 +150,8 @@ export function projectSessionRows(records: readonly SessionRecord[], options: S
122
150
  })
123
151
  .filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
124
152
  .sort((left, right) => options.sort === 'newest'
125
- ? right.createdAt - left.createdAt
126
- : left.createdAt - right.createdAt)
153
+ ? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
154
+ : left.updatedAt - right.updatedAt || left.createdAt - right.createdAt)
127
155
  }
128
156
 
129
157
  /** Merge page-local title observations without disturbing directory order. */
@@ -139,3 +167,97 @@ export function mergeSessionTitles(
139
167
  }
140
168
  return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)
141
169
  }
170
+
171
+ /**
172
+ * Encode a session id the way the JSONL backend does for its on-disk layout
173
+ * (`encodeSegment`: safe units literal, everything else `~XXXX`). Used ONLY to
174
+ * validate that a `locate()` path really is this session's directory before
175
+ * any deletion touches the filesystem — a local copy of the pure upstream
176
+ * contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
177
+ */
178
+ export function encodeSessionSegment(raw: string): string {
179
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment')
180
+ if (raw === '.') return '~002E'
181
+ if (raw === '..') return '~002E~002E'
182
+ let out = ''
183
+ for (let i = 0; i < raw.length; i += 1) {
184
+ const code = raw.charCodeAt(i)
185
+ const ch = String.fromCharCode(code)
186
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/u.test(ch)) {
187
+ out += ch
188
+ } else {
189
+ out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
190
+ }
191
+ }
192
+ return out
193
+ }
194
+
195
+ /** The session-log artifact names the JSONL backend may create. */
196
+ export const SESSION_ARTIFACT_NAMES: readonly string[] = ['session.jsonl', 'session.jsonl.zstd']
197
+
198
+ /**
199
+ * Guard one `locate()` artifact path before deletion (codex's scoped-path
200
+ * check, adapted to the JSONL layout): the file must be a `session.jsonl`
201
+ * artifact sitting in the directory named exactly `encodeSegment(id)`.
202
+ * @param artifact - the path the persistence backend located.
203
+ * @param id - the session id the artifact claims to belong to.
204
+ * @returns the owning session directory, or undefined when the layout is unexpected.
205
+ */
206
+ export function sessionArtifactDirectory(artifact: string, id: string): string | undefined {
207
+ if (basename(artifact) !== 'session.jsonl' && basename(artifact) !== 'session.jsonl.zstd') return undefined
208
+ const dir = dirname(artifact)
209
+ if (basename(dir) !== encodeSessionSegment(id)) return undefined
210
+ return dir
211
+ }
212
+
213
+ /**
214
+ * Collect one session's deletion subtree: the id plus every record whose
215
+ * parent chain leads to it (codex deletes subagent threads with their root).
216
+ * @param records - the full directory listing.
217
+ * @param id - the root session id to delete.
218
+ * @returns the ids to delete, root first.
219
+ */
220
+ export function collectDeletionSubtree(records: readonly SessionRecord[], id: string): string[] {
221
+ const parentOf = new Map<string, string | undefined>()
222
+ for (const record of records) parentOf.set(record.header.id, record.header.parentSession)
223
+ const doomed = new Set<string>([id])
224
+ // Iterate to a fixed point: children may be listed before their parents.
225
+ for (let pass = 0; pass < 2; pass += 1) {
226
+ for (const candidate of parentOf.keys()) {
227
+ if (doomed.has(candidate)) continue
228
+ let ancestor = parentOf.get(candidate)
229
+ let depth = 0
230
+ while (ancestor !== undefined && depth < 64) {
231
+ if (doomed.has(ancestor)) {
232
+ doomed.add(candidate)
233
+ break
234
+ }
235
+ ancestor = parentOf.get(ancestor)
236
+ depth += 1
237
+ }
238
+ }
239
+ }
240
+ return [...doomed]
241
+ }
242
+
243
+ /**
244
+ * Codex-style relative time for session rows ("now", "5m ago", "3h ago",
245
+ * "2d ago"; older than a week falls back to the local date).
246
+ * @param timestamp - epoch milliseconds of the last activity.
247
+ * @param now - the pinned reference clock (one value per list render).
248
+ */
249
+ export function formatRelativeTime(timestamp: number, now: number): string {
250
+ const seconds = Math.round((now - timestamp) / 1000)
251
+ if (seconds < 0) return 'now'
252
+ if (seconds < 60) return 'now'
253
+ const minutes = Math.round(seconds / 60)
254
+ if (minutes < 60) return `${minutes}m ago`
255
+ const hours = Math.round(minutes / 60)
256
+ if (hours < 24) return `${hours}h ago`
257
+ const days = Math.round(hours / 24)
258
+ if (days < 7) return `${days}d ago`
259
+ const date = new Date(timestamp)
260
+ const month = `${date.getMonth() + 1}`.padStart(2, '0')
261
+ const day = `${date.getDate()}`.padStart(2, '0')
262
+ return `${date.getFullYear()}-${month}-${day}`
263
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Live subagent activity feed: a bounded, display-only projection of CHILD
3
+ * session events. The transcript store folds only the root session (the
4
+ * durable truth this TUI renders); subagent conversations are their own
5
+ * sessions, and before this module their events were dropped entirely —
6
+ * a running subagent was invisible until its parent tool call settled.
7
+ *
8
+ * This is NOT a second transcript: each child folds to ONE row (label,
9
+ * running state, bounded last-activity text), capped at
10
+ * {@link MAX_SUBAGENT_ROWS}. Rows are advisory display state, rebuilt from
11
+ * live events; nothing here persists or replays. Notification is coalesced
12
+ * to one microtask per delivery burst, mirroring the transcript store's
13
+ * contract (per-token synchronous notify once cascaded past React's nested
14
+ * update limit on the GLM thinking path).
15
+ *
16
+ * @module @deepseek-ai/dsh-code/subagents
17
+ */
18
+
19
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
20
+
21
+ /** Hard row cap: a fan-out larger than this stays summarized by the head. */
22
+ export const MAX_SUBAGENT_ROWS = 8
23
+
24
+ /** Bounded last-activity text (plain characters, display-sliced later). */
25
+ const MAX_ACTIVITY_CHARS = 80
26
+
27
+ /** One live subagent row in the feed. */
28
+ export interface SubagentRow {
29
+ /** Child session id. */
30
+ readonly id: string
31
+ /** Display label (session title when observed, else a short id form). */
32
+ readonly label: string
33
+ /** Coarse lifecycle state folded from the child's events. */
34
+ readonly state: 'running' | 'idle' | 'done'
35
+ /** Bounded last-activity text for the status line. */
36
+ readonly activity: string
37
+ /** Last fold time (ms, event clock) — newest-first ordering key. */
38
+ readonly updatedAt: number
39
+ }
40
+
41
+ /** The read-only snapshot surface the renderer subscribes to. */
42
+ export interface SubagentFeedView {
43
+ /** Subscribe to feed changes; returns the unsubscribe function. */
44
+ subscribe(listener: () => void): () => void
45
+ /** Read the current rows (identity-stable between changes). */
46
+ getSnapshot(): readonly SubagentRow[]
47
+ }
48
+
49
+ /** Single-line bounded preview of an assembled message's text content. */
50
+ function messagePreview(content: unknown): string {
51
+ if (!Array.isArray(content)) return 'replied'
52
+ const texts: string[] = []
53
+ for (const block of content) {
54
+ if (texts.join(' ').length >= MAX_ACTIVITY_CHARS) break
55
+ if (typeof block === 'object' && block !== null) {
56
+ const { type, text } = block as Record<string, unknown>
57
+ if (type === 'text' && typeof text === 'string' && text !== '') texts.push(text)
58
+ }
59
+ }
60
+ const joined = texts.join(' ').replace(/\s+/gu, ' ').trim()
61
+ return joined === '' ? 'replied' : bound(joined)
62
+ }
63
+
64
+ /** Bound one activity string to the display budget. */
65
+ function bound(text: string): string {
66
+ const flat = text.replace(/\s+/gu, ' ').trim()
67
+ return flat.length > MAX_ACTIVITY_CHARS ? `${flat.slice(0, MAX_ACTIVITY_CHARS - 1)}…` : flat
68
+ }
69
+
70
+ /**
71
+ * Fold one child-session event into its feed row (pure).
72
+ * Unknown event kinds leave the row untouched.
73
+ * @param previous - the row's current state, when any.
74
+ * @param sessionId - the child session id.
75
+ * @param event - the child session event.
76
+ * @returns the next row state.
77
+ */
78
+ export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: string, event: SessionEvent): SubagentRow {
79
+ const base: SubagentRow = previous ?? {
80
+ id: sessionId,
81
+ label: `agent ${sessionId.slice(-6)}`,
82
+ state: 'running',
83
+ activity: 'starting…',
84
+ updatedAt: event.time,
85
+ }
86
+ const data = event.data as Record<string, unknown>
87
+ switch (event.type) {
88
+ case 'session/title': {
89
+ const title = data['title']
90
+ const text = typeof title === 'string' && title.trim() !== '' ? title : undefined
91
+ return text === undefined || text === base.label ? base : { ...base, label: bound(text), updatedAt: event.time }
92
+ }
93
+ case 'request/header':
94
+ return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
95
+ case 'user/message':
96
+ return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
97
+ case 'assistant/chunk':
98
+ return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
99
+ case 'assistant/message':
100
+ return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
101
+ case 'tool/call': {
102
+ const name = typeof data['name'] === 'string' ? data['name'] : 'tool'
103
+ return { ...base, state: 'running', activity: `tool ${name}`, updatedAt: event.time }
104
+ }
105
+ case 'tool/result':
106
+ return { ...base, state: 'running', activity: 'tool done', updatedAt: event.time }
107
+ case 'turn/start':
108
+ return { ...base, state: 'running', activity: base.activity === 'starting…' ? 'working…' : base.activity, updatedAt: event.time }
109
+ case 'turn/end':
110
+ return { ...base, state: 'done', activity: 'finished', updatedAt: event.time }
111
+ default:
112
+ // Unknown kinds leave the row untouched (identity-stable: a no-op
113
+ // fold must not churn the snapshot array).
114
+ return base
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Create one subagent feed. `apply` folds a child event (the caller gates
120
+ * which sessions are children); `reset` clears on a session switch. Row
121
+ * order is first-seen; the snapshot array is frozen and only replaced when
122
+ * a row actually changed.
123
+ * @returns the mutable feed handle plus its `SubagentFeedView`.
124
+ */
125
+ export function createSubagentFeed(): SubagentFeedView & {
126
+ apply(sessionId: string, event: SessionEvent): void
127
+ reset(): void
128
+ } {
129
+ let rows: readonly SubagentRow[] = Object.freeze([])
130
+ const listeners = new Set<() => void>()
131
+ let scheduled = false
132
+ const notify = (): void => {
133
+ if (scheduled) return
134
+ scheduled = true
135
+ queueMicrotask(() => {
136
+ scheduled = false
137
+ for (const listener of listeners) listener()
138
+ })
139
+ }
140
+ return {
141
+ apply(sessionId: string, event: SessionEvent): void {
142
+ const index = rows.findIndex(row => row.id === sessionId)
143
+ const previous = index === -1 ? undefined : rows[index]
144
+ const next = foldSubagentRow(previous, sessionId, event)
145
+ if (next === previous) return
146
+ if (index === -1 && rows.length >= MAX_SUBAGENT_ROWS) return
147
+ rows = Object.freeze(index === -1 ? [...rows, next] : rows.map((row, at) => at === index ? next : row))
148
+ notify()
149
+ },
150
+ reset(): void {
151
+ if (rows.length === 0) return
152
+ rows = Object.freeze([])
153
+ notify()
154
+ },
155
+ subscribe(listener: () => void): () => void {
156
+ listeners.add(listener)
157
+ return () => {
158
+ listeners.delete(listener)
159
+ }
160
+ },
161
+ getSnapshot(): readonly SubagentRow[] {
162
+ return rows
163
+ },
164
+ }
165
+ }