dsh-code 1.0.5 → 1.0.7

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 (47) hide show
  1. package/README.en.md +338 -286
  2. package/README.md +68 -16
  3. package/bin/deepseek.mjs +204 -4
  4. package/cordis.patch.yml +105 -7
  5. package/lib/index.mjs +2148 -439
  6. package/lib/session-query.mjs +149 -0
  7. package/lib/types/app.d.ts +34 -8
  8. package/lib/types/attachments.d.ts +36 -4
  9. package/lib/types/index.d.ts +38 -2
  10. package/lib/types/kernel-panels.d.ts +23 -0
  11. package/lib/types/provider-settings.d.ts +6 -11
  12. package/lib/types/render/animations.d.ts +74 -7
  13. package/lib/types/render/editor.d.ts +4 -3
  14. package/lib/types/render/export.d.ts +0 -6
  15. package/lib/types/render/fuzzy.d.ts +21 -0
  16. package/lib/types/render/ime-cursor.d.ts +60 -0
  17. package/lib/types/render/projection.d.ts +80 -4
  18. package/lib/types/render/status.d.ts +1 -1
  19. package/lib/types/session-directory.d.ts +48 -13
  20. package/lib/types/session-query.d.ts +92 -0
  21. package/lib/types/store.d.ts +3 -0
  22. package/lib/types/terminal-title.d.ts +58 -0
  23. package/lib/types/update-panel.d.ts +49 -0
  24. package/lib/types/update.d.ts +66 -0
  25. package/package.json +307 -162
  26. package/src/app.ts +730 -266
  27. package/src/attachments.ts +110 -11
  28. package/src/commands.ts +35 -5
  29. package/src/index.ts +1986 -1779
  30. package/src/internals.ts +66 -40
  31. package/src/kernel-panels.ts +89 -3
  32. package/src/provider-settings.ts +12 -12
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/editor.ts +5 -4
  35. package/src/render/export.ts +13 -3
  36. package/src/render/fuzzy.ts +83 -0
  37. package/src/render/ime-cursor.ts +147 -0
  38. package/src/render/projection.ts +1974 -1621
  39. package/src/render/status.ts +18 -4
  40. package/src/session-directory.ts +94 -16
  41. package/src/session-query.ts +235 -0
  42. package/src/skills.ts +23 -9
  43. package/src/store.ts +39 -1
  44. package/src/subagents.ts +26 -3
  45. package/src/terminal-title.ts +173 -0
  46. package/src/update-panel.ts +246 -0
  47. package/src/update.ts +110 -0
@@ -77,6 +77,7 @@ export type StatusTone =
77
77
  | 'meta'
78
78
  | 'accent'
79
79
  | 'success'
80
+ | 'plan'
80
81
  | 'warn'
81
82
  | 'error'
82
83
  // Context-bar fill: one DeepSeek blue for the whole occupied run (the free
@@ -419,9 +420,7 @@ function buildCandidates(
419
420
  const right: { span: StatusSpan; rank: number; id: string }[] = []
420
421
  const row2: { group: StatusGroup; rank: number; id: string }[] = []
421
422
 
422
- if (facts.plan && enabled.has('plan')) {
423
- row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
424
- }
423
+
425
424
 
426
425
  if (stats.turns > 0 || stats.steps > 0) {
427
426
  if (enabled.has('turns')) {
@@ -522,10 +521,25 @@ function buildCandidates(
522
521
  }
523
522
  const permission = safe(facts.permission)
524
523
  let badge = -1
524
+ // The plan STATION names itself in the permission badge: with the most
525
+ // restrictive preset active, plan mode reads as the green fourth cycle
526
+ // station 'plan' (that preset IS the station's permission layer). Plan on
527
+ // any other preset (a typed /plan mid-session) stays orthogonal: the badge
528
+ // keeps naming the preset and row 2 carries the green plan marker.
529
+ const planStation = facts.plan && permissionTone(permission) === 'success'
525
530
  if (permission !== '' && enabled.has('permission')) {
526
- right.push({ span: { text: permission, tone: permissionTone(permission) }, rank: RANK_BADGE, id: 'permission' })
531
+ right.push({
532
+ span: planStation
533
+ ? { text: 'plan on', tone: 'plan' }
534
+ : { text: permission, tone: permissionTone(permission) },
535
+ rank: RANK_BADGE,
536
+ id: 'permission',
537
+ })
527
538
  badge = right.length - 1
528
539
  }
540
+ if (facts.plan && enabled.has('plan')) {
541
+ row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
542
+ }
529
543
  return { left, right, badge, row2 }
530
544
  }
531
545
 
@@ -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
  /**
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Skip-tolerant session-query engine for this terminal.
3
+ *
4
+ * The upstream SqliteSessionQueryEngine reconciliation observes EVERY
5
+ * persisted session before each search, and ONE unreadable source (for
6
+ * example a pre-release session artifact the frozen format codecs reject)
7
+ * fails the whole pass with SESSION_QUERY_PERSISTENCE_FAILED — every
8
+ * cross-session search dies because of a single old file nobody opened
9
+ * otherwise. This subclass overrides only the observation loop so an
10
+ * unreadable source is skipped with a warning and the rest of the corpus
11
+ * indexes normally; skipped sessions are retried on later reconciliations
12
+ * and rejoin automatically once a host that can read them is installed.
13
+ *
14
+ * Vendored surface note: `_observeStable` and its module-local helpers are
15
+ * private upstream; this file re-declares the observation loop against the
16
+ * pinned @deepseek-ai line (see package.json peers) and must be re-checked
17
+ * whenever that line moves. The engine class and the tool boundary also
18
+ * share ONE physical parent-package instance through this bundle, which
19
+ * restores instanceof-based typed error messages on the search path.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import SqliteSessionQueryEngine, { type Config } from '@deepseek-ai/dsh-session-query-sqlite'
24
+ import {
25
+ assertSessionHeadersCompatible,
26
+ buildSessionEventSearchDocuments,
27
+ readColdSessionLog,
28
+ SessionQueryError,
29
+ } from '@deepseek-ai/dsh-session-query'
30
+ import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
31
+ import type { SessionPersistenceRevision, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
32
+
33
+ /** One observed session: detached header plus its derived search documents. */
34
+ interface ObservedSession {
35
+ header: SessionHeader
36
+ inheritedEventCount: SessionLogOffset
37
+ documents: readonly ReturnType<typeof buildSessionEventSearchDocuments>[number][]
38
+ fingerprint: string
39
+ }
40
+
41
+ /** One persisted snapshot as the reconciliation sees it (loaded once readable). */
42
+ interface ObservedPersistedSession {
43
+ header: SessionHeader
44
+ revision: SessionPersistenceRevision
45
+ loaded?: ObservedSession
46
+ /** Diagnosis for a cold read that failed; the session stays unindexed. */
47
+ unreadable?: string
48
+ }
49
+
50
+ /** The engine-internal state the observation loop touches. */
51
+ export interface EngineSurface {
52
+ readonly ctx: {
53
+ sessions: {
54
+ list(): readonly { header: SessionHeader; inheritedEventCount: SessionLogOffset; snapshotEvents(): readonly SessionEvent[]; id: SessionId }[]
55
+ get(id: SessionId): unknown
56
+ }
57
+ logger?: { warn(format: string, ...args: readonly unknown[]): void }
58
+ }
59
+ readonly _persistenceBinding: {
60
+ readonly identity: symbol
61
+ readonly service?: {
62
+ list(options?: { readonly signal?: AbortSignal }): Promise<readonly SessionPersistenceSnapshot[]>
63
+ }
64
+ }
65
+ _lastPersistenceIdentity: symbol | undefined
66
+ }
67
+
68
+ type ColdRead = (persistence: NonNullable<EngineSurface['_persistenceBinding']['service']>, id: SessionId, signal: AbortSignal | undefined) => Promise<{ header: SessionHeader; inheritedEventCount: SessionLogOffset; events: readonly SessionEvent[] }>
69
+
70
+ const STABLE_OBSERVATION_ATTEMPTS = 2
71
+
72
+ function assertNotAborted(signal: AbortSignal | undefined): void {
73
+ if (signal?.aborted) {
74
+ throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')
75
+ }
76
+ }
77
+
78
+ function isAbort(error: unknown): boolean {
79
+ return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED'
80
+ }
81
+
82
+ function errorMessage(error: unknown): string {
83
+ return error instanceof Error ? error.message : 'unknown error'
84
+ }
85
+
86
+ function observeSession(header: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[]): ObservedSession {
87
+ const detachedHeader = structuredClone(header)
88
+ const detachedEvents = events.map(event => structuredClone(event))
89
+ return {
90
+ header: detachedHeader,
91
+ inheritedEventCount,
92
+ documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
93
+ fingerprint: createHash('sha256')
94
+ .update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents }))
95
+ .digest('base64url'),
96
+ }
97
+ }
98
+
99
+ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
100
+ return a.id === b.id
101
+ && a.createdAt === b.createdAt
102
+ && a.cwd === b.cwd
103
+ && a.parentSession === b.parentSession
104
+ && a.isSeeded === b.isSeeded
105
+ && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
106
+ && a.agentPreset === b.agentPreset
107
+ }
108
+
109
+ function materializePersistenceSnapshots(snapshots: readonly SessionPersistenceSnapshot[]): Map<SessionId, ObservedPersistedSession> {
110
+ if (!Array.isArray(snapshots)) throw new Error('persistence snapshots must be an array')
111
+ const result = new Map<SessionId, ObservedPersistedSession>()
112
+ for (const snapshot of snapshots) {
113
+ if (typeof snapshot.revision !== 'string') {
114
+ throw new Error('persistence snapshot revision must be a string')
115
+ }
116
+ const header = structuredClone(snapshot.header)
117
+ if (result.has(header.id)) {
118
+ throw new Error(`persistence listed duplicate session "${header.id}"`)
119
+ }
120
+ result.set(header.id, { header, revision: snapshot.revision })
121
+ }
122
+ return result
123
+ }
124
+
125
+ function samePersistenceSnapshots(before: ReadonlyMap<SessionId, ObservedPersistedSession>, after: ReadonlyMap<SessionId, ObservedPersistedSession>): boolean {
126
+ if (before.size !== after.size) return false
127
+ for (const [id, first] of before) {
128
+ const second = after.get(id)
129
+ if (second === undefined || first.revision !== second.revision || !sameHeader(first.header, second.header)) return false
130
+ }
131
+ return true
132
+ }
133
+
134
+ /**
135
+ * The skip-tolerant observation pass: structurally the upstream loop, with
136
+ * the per-source cold read wrapped so one unreadable session degrades to a
137
+ * warning instead of failing every search. Exported for unit tests with an
138
+ * injectable cold reader.
139
+ */
140
+ export async function observeStableWithSkip(
141
+ engine: EngineSurface,
142
+ indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>,
143
+ signal: AbortSignal | undefined,
144
+ readCold: ColdRead = readColdSessionLog as unknown as ColdRead,
145
+ ): Promise<{ persistenceBinding: EngineSurface['_persistenceBinding']; persisted: Map<SessionId, ObservedPersistedSession>; live: Map<SessionId, ObservedSession> }> {
146
+ for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) {
147
+ assertNotAborted(signal)
148
+ const persistenceBinding = engine._persistenceBinding
149
+ const persistence = persistenceBinding.service
150
+ const initiallyLive = new Set(engine.ctx.sessions.list().map(session => session.id))
151
+ let persisted = new Map<SessionId, ObservedPersistedSession>()
152
+ if (persistence !== undefined) {
153
+ try {
154
+ const canReuseIndexed = engine._lastPersistenceIdentity === undefined
155
+ || engine._lastPersistenceIdentity === persistenceBinding.identity
156
+ const listOptions = signal === undefined ? undefined : { signal }
157
+ const before = await persistence.list(listOptions)
158
+ assertNotAborted(signal)
159
+ persisted = materializePersistenceSnapshots(before)
160
+ for (const entry of persisted.values()) {
161
+ if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue
162
+ if (initiallyLive.has(entry.header.id) || engine.ctx.sessions.get(entry.header.id) !== undefined) continue
163
+ assertNotAborted(signal)
164
+ // The skip: a session whose cold log fails to migrate or decode
165
+ // stays OUT of the index with one warning; the remaining corpus
166
+ // indexes normally. `loaded` stays undefined exactly like a
167
+ // not-yet-read entry, so the stable-snapshot comparison and the
168
+ // live-preferred merge below are unaffected.
169
+ try {
170
+ const loaded = await readCold(persistence, entry.header.id, signal)
171
+ assertNotAborted(signal)
172
+ assertSessionHeadersCompatible(entry.header, loaded.header)
173
+ entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events)
174
+ } catch (error: unknown) {
175
+ if (isAbort(error) || signal?.aborted) throw error
176
+ if (engine._persistenceBinding !== persistenceBinding) break
177
+ entry.unreadable = errorMessage(error)
178
+ engine.ctx.logger?.warn(
179
+ 'session-search skipped unreadable session %s: %s',
180
+ entry.header.id,
181
+ entry.unreadable,
182
+ )
183
+ }
184
+ }
185
+ assertNotAborted(signal)
186
+ const afterSnapshots = await persistence.list(listOptions)
187
+ assertNotAborted(signal)
188
+ const after = materializePersistenceSnapshots(afterSnapshots)
189
+ if (!samePersistenceSnapshots(persisted, after)) continue
190
+ if (engine._persistenceBinding !== persistenceBinding) continue
191
+ } catch (error: unknown) {
192
+ if (isAbort(error) || signal?.aborted) {
193
+ throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { cause: error })
194
+ }
195
+ if (engine._persistenceBinding !== persistenceBinding) continue
196
+ if (error instanceof SessionQueryError) throw error
197
+ throw new SessionQueryError(
198
+ `session-search persistence observation failed: ${errorMessage(error)}`,
199
+ 'SESSION_QUERY_PERSISTENCE_FAILED',
200
+ { cause: error },
201
+ )
202
+ }
203
+ }
204
+ const live = new Map<SessionId, ObservedSession>()
205
+ for (const session of engine.ctx.sessions.list()) {
206
+ const observed = observeSession(session.header, session.inheritedEventCount, session.snapshotEvents())
207
+ const durable = persisted.get(session.id)
208
+ if (durable !== undefined && durable.loaded === undefined) {
209
+ // A live owner always wins over a skipped durable copy.
210
+ live.set(session.id, observed)
211
+ continue
212
+ }
213
+ if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header as SessionHeader)
214
+ live.set(session.id, observed)
215
+ }
216
+ const sameLive = initiallyLive.size === live.size && [...initiallyLive].every(id => live.has(id))
217
+ if (!sameLive) continue
218
+ return { persistenceBinding, persisted, live }
219
+ }
220
+ throw new SessionQueryError('session-search persistence observation did not stabilize after one retry', 'SESSION_QUERY_PERSISTENCE_FAILED')
221
+ }
222
+
223
+ // The opaque-base cast keeps the private `_observeStable` override legal in
224
+ // TypeScript while inheriting every runtime static (inject, Config, Service
225
+ // metadata) from the real engine class.
226
+ const EngineBase = SqliteSessionQueryEngine as unknown as abstract new (ctx: never, config: Config) => EngineSurface & object
227
+
228
+ /** The engine this bundle mounts in place of the base `session-query-sqlite` row. */
229
+ export class SkipTolerantSessionQueryEngine extends EngineBase {
230
+ async _observeStable(indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>, signal: AbortSignal | undefined): Promise<unknown> {
231
+ return await observeStableWithSkip(this as unknown as EngineSurface, indexed, signal)
232
+ }
233
+ }
234
+
235
+ export default SkipTolerantSessionQueryEngine as unknown as typeof SqliteSessionQueryEngine
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