dsh-code 1.0.4 → 1.0.6

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 (41) hide show
  1. package/README.en.md +287 -286
  2. package/README.md +16 -13
  3. package/bin/deepseek.mjs +336 -11
  4. package/cordis.patch.yml +26 -18
  5. package/lib/index.mjs +1310 -439
  6. package/lib/types/app.d.ts +25 -6
  7. package/lib/types/attachments.d.ts +36 -4
  8. package/lib/types/git-workflow.d.ts +7 -2
  9. package/lib/types/history.d.ts +18 -11
  10. package/lib/types/index.d.ts +11 -2
  11. package/lib/types/presets.d.ts +4 -1
  12. package/lib/types/provider-settings.d.ts +6 -11
  13. package/lib/types/questions.d.ts +16 -12
  14. package/lib/types/render/animations.d.ts +74 -7
  15. package/lib/types/render/export.d.ts +0 -6
  16. package/lib/types/render/fuzzy.d.ts +21 -0
  17. package/lib/types/render/projection.d.ts +47 -5
  18. package/lib/types/session-directory.d.ts +48 -13
  19. package/lib/types/settings-file.d.ts +8 -0
  20. package/lib/types/store.d.ts +3 -0
  21. package/package.json +168 -159
  22. package/src/app.ts +480 -199
  23. package/src/attachments.ts +110 -11
  24. package/src/commands.ts +35 -5
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +1868 -1752
  28. package/src/internals.ts +61 -40
  29. package/src/permissions.ts +1 -1
  30. package/src/presets.ts +19 -6
  31. package/src/provider-settings.ts +12 -12
  32. package/src/questions.ts +57 -74
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/export.ts +20 -10
  35. package/src/render/fuzzy.ts +83 -0
  36. package/src/render/projection.ts +1833 -1620
  37. package/src/session-directory.ts +94 -16
  38. package/src/settings-file.ts +38 -6
  39. package/src/skills.ts +23 -9
  40. package/src/store.ts +39 -1
  41. package/src/subagents.ts +26 -3
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename, dirname, resolve } from 'node:path'
4
4
  import { realpathSync } from 'node:fs'
5
- import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
5
+ import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
6
6
 
7
7
  export interface SessionRecord {
8
8
  readonly header: SessionHeader
@@ -170,9 +170,8 @@ export function mergeSessionTitles(
170
170
 
171
171
  /**
172
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
173
+ * (`encodeSegment`: safe units literal, everything else `~XXXX`). Used to
174
+ * validate and derive session directories a local copy of the pure upstream
176
175
  * contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
177
176
  */
178
177
  export function encodeSessionSegment(raw: string): string {
@@ -192,22 +191,101 @@ export function encodeSessionSegment(raw: string): string {
192
191
  return out
193
192
  }
194
193
 
195
- /** The session-log artifact names the JSONL backend may create. */
196
- export const SESSION_ARTIFACT_NAMES: readonly string[] = ['session.jsonl', 'session.jsonl.zstd']
194
+ /**
195
+ * Encode a project cwd the way the JSONL backend groups sessions on disk
196
+ * (`projectKey`: separators collapse to one `-`, everything else mirrors
197
+ * `encodeSegment`, bounded to 251 chars). A local copy of the pure upstream
198
+ * contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
199
+ */
200
+ export function encodeProjectKey(cwd: string): string {
201
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path')
202
+ let readable = ''
203
+ let separatorRun = false
204
+ for (let i = 0; i < cwd.length; i += 1) {
205
+ const code = cwd.charCodeAt(i)
206
+ const ch = String.fromCharCode(code)
207
+ if (ch === '/' || ch === '\\' || ch === ':') {
208
+ if (!separatorRun) readable += '-'
209
+ separatorRun = true
210
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
211
+ readable += ch
212
+ separatorRun = false
213
+ } else {
214
+ readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
215
+ separatorRun = false
216
+ }
217
+ }
218
+ const slug = readable.replace(/^-+/, '') || 'root'
219
+ return `--${slug.slice(0, 251)}--`
220
+ }
221
+
222
+ /** The project-level directory name the JSONL backend uses for a missing cwd. */
223
+ const NO_CWD_DIRECTORY = '_no-cwd'
224
+
225
+ /**
226
+ * Derive one session's artifact directory under the JSONL backend root,
227
+ * mirroring the upstream `<root>/<projectKey(cwd)>/<encodeSegment(id)>/`
228
+ * layout (0.1.5 `sessionDir`/`projectDir`).
229
+ * @param root - the JSONL backend's configured session root.
230
+ * @param cwd - the session's pinned working directory, when the header has one.
231
+ * @param id - the session id.
232
+ * @returns the absolute session directory path.
233
+ */
234
+ export function sessionDirectoryFor(root: string, cwd: string | undefined, id: string): string {
235
+ const project = cwd === undefined || cwd === '' ? NO_CWD_DIRECTORY : encodeProjectKey(cwd)
236
+ return resolve(root, project, encodeSessionSegment(id))
237
+ }
197
238
 
198
239
  /**
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.
240
+ * The canonical session-log artifact filenames the JSONL backend may create:
241
+ * format v0 writes the bare `session.jsonl` name; v1+ write
242
+ * `session.vN.jsonl`, each generation optionally zstd-compressed. Multiple
243
+ * immutable generations may coexist in one session directory (0.1.5). The
244
+ * range follows the installed session package's `SESSION_FORMAT_VERSION`, so
245
+ * a future generation joins the enumeration with the dependency bump.
205
246
  */
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)
247
+ export function sessionArtifactNames(): readonly string[] {
248
+ const names: string[] = ['session.jsonl', 'session.jsonl.zstd']
249
+ for (let version = 1; version <= SESSION_FORMAT_VERSION; version += 1) {
250
+ names.push(`session.v${version}.jsonl`, `session.v${version}.jsonl.zstd`)
251
+ }
252
+ return names
253
+ }
254
+
255
+ /** Canonical generation-log filenames as a lookup set (bare v0 or `vN`-suffixed, ± zstd). */
256
+ const SESSION_ARTIFACT_NAME_SET: ReadonlySet<string> = new Set(sessionArtifactNames())
257
+
258
+ /** True for one canonical session-log artifact filename the backend may own. */
259
+ export function isSessionArtifactName(name: string): boolean {
260
+ return SESSION_ARTIFACT_NAME_SET.has(name)
261
+ }
262
+
263
+ /**
264
+ * Guard a derived session directory before deletion (codex's scoped-path
265
+ * check, adapted to the JSONL layout): the directory's base name must be
266
+ * exactly `encodeSegment(id)` beneath its project grouping.
267
+ * @param dir - the derived session artifact directory.
268
+ * @param id - the session id the directory claims to belong to.
269
+ * @returns the guarded directory, or undefined when the layout is unexpected.
270
+ */
271
+ export function sessionArtifactDirectory(dir: string, id: string): string | undefined {
209
272
  if (basename(dir) !== encodeSessionSegment(id)) return undefined
210
- return dir
273
+ if (basename(dirname(dir)) === NO_CWD_DIRECTORY) return dir
274
+ return /^--.*--$|^~/.test(basename(dirname(dir))) ? dir : undefined
275
+ }
276
+
277
+ /**
278
+ * The JSONL backend's configured session root, when the mounted backend
279
+ * exposes one. The upstream service contract dropped `locate()` in 0.1.5
280
+ * (artifact paths are backend-private; only refusal diagnostics carry them),
281
+ * so the TUI derives artifact paths from the backend's public plugin config.
282
+ * Backends without a JSONL-style config (or a foreign shape) yield undefined
283
+ * and callers degrade: mtime sorting falls back to createdAt and /delete
284
+ * refuses, exactly as before.
285
+ */
286
+ export function jsonlSessionRoot(persistence: unknown): string | undefined {
287
+ const root = (persistence as { config?: { root?: unknown } } | undefined)?.config?.root
288
+ return typeof root === 'string' && root !== '' ? root : undefined
211
289
  }
212
290
 
213
291
  /**
@@ -16,9 +16,46 @@
16
16
  * @module @deepseek-ai/dsh-code/settings-file
17
17
  */
18
18
 
19
+ import { randomUUID } from 'node:crypto'
19
20
  import { mkdir, rename, writeFile } from 'node:fs/promises'
20
21
  import { dirname } from 'node:path'
21
22
 
23
+ /**
24
+ * Run one file operation with a bounded retry: one initial try plus at
25
+ * most `retries` more. Creating or replacing a file can fail transiently
26
+ * with EPERM/EACCES while an antivirus scanner or search indexer holds
27
+ * it — the standard graceful-fs remedy, not a workaround for a
28
+ * persistent permission problem. A save that still fails leaves its
29
+ * uniquely named temp file behind, so repeated crashed saves accumulate
30
+ * distinct leftovers rather than corrupting a shared one.
31
+ */
32
+ async function withTransientRetry(operation: () => Promise<void>, retries = 5): Promise<void> {
33
+ for (let attempt = 0; ; attempt += 1) {
34
+ try {
35
+ await operation()
36
+ return
37
+ } catch (error: unknown) {
38
+ const code = (error as NodeJS.ErrnoException).code
39
+ if (attempt >= retries || (code !== 'EPERM' && code !== 'EACCES')) throw error
40
+ await new Promise(resolve => setTimeout(resolve, 30 * (attempt + 1)))
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Write one file atomically: create the parent directory, write to a
47
+ * uniquely named temp file, and rename it into place. A crash midway
48
+ * can never leave a half-written document behind. Unique temp names
49
+ * keep concurrent writers (two terminals, two chains in one process)
50
+ * from sharing one temp path.
51
+ */
52
+ export async function writeFileAtomically(path: string, text: string): Promise<void> {
53
+ await mkdir(dirname(path), { recursive: true })
54
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
55
+ await withTransientRetry(() => writeFile(temp, text, 'utf8'))
56
+ await withTransientRetry(() => rename(temp, path))
57
+ }
58
+
22
59
  /** The serialized persistence surface; flush() is handed to the quit sequence. */
23
60
  export interface UserSettingsPersistence {
24
61
  /**
@@ -39,12 +76,7 @@ export function createUserSettingsPersistence(): UserSettingsPersistence {
39
76
  let chain: Promise<void> = Promise.resolve()
40
77
  return {
41
78
  save(path: string, text: string): Promise<void> {
42
- const write = chain.then(async () => {
43
- await mkdir(dirname(path), { recursive: true })
44
- const temp = `${path}.tmp`
45
- await writeFile(temp, text, 'utf8')
46
- await rename(temp, path)
47
- })
79
+ const write = chain.then(() => writeFileAtomically(path, text))
48
80
  // A failed write must not break the chain for later saves.
49
81
  chain = write.catch(() => {})
50
82
  return write
package/src/skills.ts CHANGED
@@ -74,11 +74,17 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
74
74
 
75
75
  const reload = (): void => {
76
76
  const target = agent
77
- if (skills === undefined || target === undefined) return
78
- Promise.resolve().then(() => skills.list({
79
- cwd: target.session.header.cwd ?? fallbackCwd,
80
- scope: target,
81
- })).then((summaries: readonly SkillSummary[]) => {
77
+ if (skills === undefined) return
78
+ Promise.resolve().then(() => skills.list(target === undefined
79
+ // No session exists yet (a bare launch keeps the agent unset until the
80
+ // first message): read the global skill layer for the working directory.
81
+ // The upstream contract makes `scope` optional — omitted reads the
82
+ // global layer alone — so the menu offers skills before a session does.
83
+ ? { cwd: fallbackCwd }
84
+ : {
85
+ cwd: target.session.header.cwd ?? fallbackCwd,
86
+ scope: target,
87
+ })).then((summaries: readonly SkillSummary[]) => {
82
88
  // A retarget landed while this catalog was loading: the rows belong to
83
89
  // another agent's workspace and must never overwrite the current view.
84
90
  if (agent !== target) return
@@ -97,19 +103,27 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
97
103
  for (const listener of listeners) listener()
98
104
  }).catch((cause: unknown) => {
99
105
  if (agent !== target) return
100
- // Discovery failure keeps the last good rows for the SAME agent (the
106
+ // Discovery failure keeps the last good rows for the SAME target (the
101
107
  // next skills/change notification is the retry surface, mirroring the
102
- // web directory); an agent that never loaded starts from empty rows —
108
+ // web directory); a target that never loaded starts from empty rows —
103
109
  // stale rows from a previous workspace must not keep completing here.
110
+ // The rows array keeps its identity unless the failure text itself
111
+ // changed: a repeated identical error on the 0.1.5 event storm must not
112
+ // churn fresh identities into React's update chain.
113
+ const nextError = cause instanceof Error ? cause.message : String(cause)
104
114
  if (loadedFor !== target) rows = []
105
- else rows = [...rows]
106
- error = cause instanceof Error ? cause.message : String(cause)
115
+ const errorChanged = nextError !== error
116
+ error = nextError
117
+ if (!errorChanged) return
107
118
  for (const listener of listeners) listener()
108
119
  })
109
120
  }
110
121
 
111
122
  if (skills !== undefined) {
112
123
  ctx.on('skills/change', reload)
124
+ // Read the global layer immediately: a bare launch has no agent yet, and
125
+ // waiting for the first skills/change would leave the menu empty.
126
+ reload()
113
127
  }
114
128
 
115
129
  const view: SkillsWatch = {
package/src/store.ts CHANGED
@@ -30,8 +30,16 @@
30
30
  * @module @deepseek-ai/dsh-tui/store
31
31
  */
32
32
 
33
+ import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
33
34
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
34
- import { createReplayAccumulator, replayProjectEvent, snapshotReplayView, type TranscriptView } from './render/projection.ts'
35
+ import {
36
+ applyAssistantStreamChunk,
37
+ clearAssistantStream,
38
+ createReplayAccumulator,
39
+ replayProjectEvent,
40
+ snapshotReplayView,
41
+ type TranscriptView,
42
+ } from './render/projection.ts'
35
43
 
36
44
  /** Render frame budget: the notification cadence's upper bound. */
37
45
  const NOTIFY_FRAME_MS = 16
@@ -44,6 +52,8 @@ export interface TranscriptStore {
44
52
  subscribe(listener: () => void): () => void
45
53
  /** Fold one session event; ignored events change nothing and notify nobody. */
46
54
  apply(event: SessionEvent): void
55
+ /** Fold one live assistant-stream frame; frames without visible deltas stay silent. */
56
+ applyStreamFrame(frame: AssistantStreamFrame): void
47
57
  /** Drop the folded view entirely (/clear): the next event starts a fresh one. */
48
58
  reset(): void
49
59
  }
@@ -66,6 +76,11 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
66
76
  const listeners = new Set<() => void>()
67
77
  let scheduled = false
68
78
  let lastNotifyAt = 0
79
+ // Live attempt → `turn:step` key: chunk frames name only their attempt, so
80
+ // the start frame's turn/step anchor is remembered until the end frame
81
+ // retires the attempt. A replacement attempt (new start frame) overwrites
82
+ // the entry; committed settlements already cleared the tails it replaces.
83
+ const attemptKeys = new Map<string, string>()
69
84
  const notify = (): void => {
70
85
  if (scheduled) return
71
86
  scheduled = true
@@ -101,8 +116,31 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
101
116
  dirty = true
102
117
  notify()
103
118
  },
119
+ applyStreamFrame(frame: AssistantStreamFrame): void {
120
+ if (frame.type === 'start') {
121
+ attemptKeys.set(frame.attemptId, `${frame.turn}:${frame.step}`)
122
+ return
123
+ }
124
+ if (frame.type === 'chunk') {
125
+ const key = attemptKeys.get(frame.attemptId)
126
+ if (key === undefined) return
127
+ if (!applyAssistantStreamChunk(acc, key, frame.time, frame.chunk)) return
128
+ dirty = true
129
+ notify()
130
+ return
131
+ }
132
+ // End frame: committed settlements arrive as durable events before
133
+ // their end frame and already cleared the tails; an abandoned attempt
134
+ // has no settlement, so its partial tail is dropped here.
135
+ attemptKeys.delete(frame.attemptId)
136
+ if (frame.outcome.kind === 'abandoned' && clearAssistantStream(acc)) {
137
+ dirty = true
138
+ notify()
139
+ }
140
+ },
104
141
  reset(): void {
105
142
  acc = createReplayAccumulator()
143
+ attemptKeys.clear()
106
144
  dirty = true
107
145
  notify()
108
146
  },
package/src/subagents.ts CHANGED
@@ -22,6 +22,9 @@
22
22
  */
23
23
 
24
24
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
25
+ // Type-only import merges the subagent package's SessionEventMap variant
26
+ // ('subagent/catalog') into the union this fold switches on.
27
+ import type {} from '@deepseek-ai/dsh-subagent'
25
28
 
26
29
  /** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
27
30
  export const MAX_SUBAGENT_ROWS = 8
@@ -107,8 +110,27 @@ export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: st
107
110
  return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
108
111
  case 'user/message':
109
112
  return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
110
- case 'assistant/chunk':
113
+ case 'assistant/attempt':
114
+ // Durable logs are settlement-only since session-log v2; an attempt
115
+ // landing without a surface message means the model is retrying or
116
+ // recovered from a stream error, so the child stays running.
111
117
  return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
118
+ case 'subagent/catalog': {
119
+ // Parent-owned durable discovery fact (0.1.5): the catalog names the
120
+ // child's mode (one-shot vs continuable) and its authored label — the
121
+ // most semantic label the row can carry. It is a discovery fact, not a
122
+ // lifecycle signal: a fresh row starts idle, but a late delivery never
123
+ // regresses a row that already ran or finished. An unchanged fact keeps
124
+ // the row's identity (the no-op discipline of the default branch), so
125
+ // repeated deliveries never churn the snapshot array.
126
+ const mode = event.data.mode === 'continuable' ? 'continuable' : 'one-shot'
127
+ const label = event.data.label !== undefined && event.data.label.trim() !== '' ? bound(event.data.label) : undefined
128
+ const nextLabel = label === undefined ? base.label : label
129
+ const activity = label === undefined ? `catalog · ${mode}` : `catalog · ${mode} · ${label}`
130
+ const state = previous === undefined ? 'idle' : base.state
131
+ if (nextLabel === base.label && state === base.state && activity === base.activity) return base
132
+ return { ...base, state, label: nextLabel, activity, updatedAt: event.time }
133
+ }
112
134
  case 'assistant/message':
113
135
  return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
114
136
  case 'tool/call': {
@@ -169,11 +191,12 @@ export function createSubagentFeed(): SubagentFeedView & {
169
191
  }
170
192
  // A child this feed has not shown yet: the honest total grows even
171
193
  // when every row is busy; admission then prefers evicting the OLDEST
172
- // settled row so a new running agent never waits on one that finished.
194
+ // settled row (idle or done both are non-running) so a new running
195
+ // agent never waits on one that already settled.
173
196
  const counted = !seen.has(sessionId)
174
197
  if (counted) seen.add(sessionId)
175
198
  if (rows.length >= MAX_SUBAGENT_ROWS) {
176
- const evict = rows.findIndex(row => row.state === 'done')
199
+ const evict = rows.findIndex(row => row.state !== 'running')
177
200
  if (evict === -1) {
178
201
  if (counted) notify()
179
202
  return