dsh-code 0.4.0 → 0.6.0

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 (49) hide show
  1. package/README.en.md +217 -0
  2. package/README.md +216 -67
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/devtools-CdTl3MNy.mjs +3643 -0
  6. package/lib/index.mjs +27467 -673
  7. package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
  8. package/lib/startup.mjs +34 -17
  9. package/lib/types/app.d.ts +29 -1
  10. package/lib/types/commands.d.ts +2 -0
  11. package/lib/types/history.d.ts +79 -0
  12. package/lib/types/index.d.ts +5 -5
  13. package/lib/types/internals.d.ts +2 -0
  14. package/lib/types/kernel-panels.d.ts +48 -0
  15. package/lib/types/plugin-inventory.d.ts +11 -0
  16. package/lib/types/presets.d.ts +32 -0
  17. package/lib/types/render/animations.d.ts +10 -1
  18. package/lib/types/render/inspector.d.ts +6 -0
  19. package/lib/types/render/projection.d.ts +21 -1
  20. package/lib/types/render/status.d.ts +131 -14
  21. package/lib/types/render/text.d.ts +9 -0
  22. package/lib/types/session-directory.d.ts +54 -0
  23. package/lib/types/session-switch.d.ts +17 -0
  24. package/lib/types/skills.d.ts +2 -0
  25. package/lib/types/startup.d.ts +11 -1
  26. package/package.json +117 -112
  27. package/src/app.ts +2543 -1969
  28. package/src/commands.ts +15 -1
  29. package/src/history.ts +136 -0
  30. package/src/index.ts +550 -155
  31. package/src/internals.ts +5 -0
  32. package/src/kernel-panels.ts +419 -0
  33. package/src/plugin-inventory.ts +47 -0
  34. package/src/presets.ts +64 -0
  35. package/src/render/animations.ts +14 -1
  36. package/src/render/export.ts +4 -0
  37. package/src/render/inspector.ts +23 -5
  38. package/src/render/lines.ts +21 -10
  39. package/src/render/markdown.ts +15 -1
  40. package/src/render/projection.ts +71 -8
  41. package/src/render/status.ts +522 -65
  42. package/src/render/text.ts +34 -6
  43. package/src/session-directory.ts +102 -0
  44. package/src/session-switch.ts +58 -0
  45. package/src/skills.ts +20 -7
  46. package/src/startup.ts +38 -20
  47. package/src/whale-glyph.ts +23 -23
  48. package/README.zh.md +0 -65
  49. package/src/pictures/1.png +0 -0
package/src/index.ts CHANGED
@@ -1,26 +1,26 @@
1
1
  /**
2
2
  * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
3
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
4
- * creates (or resumes) one Agent through the core registry, mounts the Ink
5
- * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
6
- * durable session, answers approval asks with a y/n bar, dispatches slash
7
- * commands through the shared registry, and on quit flushes and requests
8
- * process exit.
4
+ * creates or resumes preset-composed Agents through the core registry, keeps
5
+ * one Ink owner while the active session changes, folds submitted prompts
6
+ * into the selected durable session, answers approval asks with a y/n bar,
7
+ * dispatches slash commands, and on quit flushes and requests process exit.
9
8
  *
10
9
  * @module @deepseek-ai/dsh-code
11
10
  */
12
11
 
13
12
  import { randomUUID } from 'node:crypto'
14
13
  import { readFileSync } from 'node:fs'
15
- import { writeFile as writeFileAsync } from 'node:fs/promises'
16
- import { basename, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
16
+ import { basename, dirname, join } from 'node:path'
17
17
  import { createElement } from 'react'
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import z from '@deepseek-ai/schemastery'
20
20
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
- import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
+ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
22
  import type {} from '@deepseek-ai/dsh-agent-default-model'
23
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
+ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
24
24
  import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
25
25
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
26
26
  // Type-only: carries the ctx.sessionTitle service merge for /title.
@@ -29,18 +29,30 @@ import type {} from '@deepseek-ai/dsh-session-title'
29
29
  // and the cmdline Context merge for the appExit host value.
30
30
  import type {} from '@deepseek-ai/cordis-plugin-loader'
31
31
  import type {} from '@deepseek-ai/dsh-cmdline'
32
- import { App } from './app.ts'
32
+ import { App, type NoticeTone } from './app.ts'
33
33
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
34
34
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
35
35
  import { internals, type TuiMount } from './internals.ts'
36
36
  import { loadModelDirectory, type ModelRow } from './models.ts'
37
- import { createMentions, type MentionsApi } from './mentions.ts'
37
+ import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
38
38
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
39
- import { createTranscriptStore } from './store.ts'
39
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
40
+ import { parseStatuslineItems } from './render/status.ts'
41
+ import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
40
42
  import { watchSkills, type SkillsView } from './skills.ts'
41
43
  import { toolArgumentsPreview } from './render/tool-preview.ts'
42
44
  import { buildExportMarkdown } from './render/export.ts'
43
45
  import type { TuiStartup } from './startup.ts'
46
+ import { SessionSwitchQueue } from './session-switch.ts'
47
+ import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
48
+ import { listPluginRows } from './plugin-inventory.ts'
49
+ import {
50
+ mergeSessionTitles,
51
+ projectSessionRows,
52
+ type SessionDirectoryOptions,
53
+ type SessionQueryService,
54
+ type SessionRow,
55
+ } from './session-directory.ts'
44
56
 
45
57
  /** Stable Cordis plugin name. */
46
58
  export const name = 'tui-runner'
@@ -51,13 +63,14 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
51
63
  /** Plugin config: the startup resolved from this app's injected provider service. */
52
64
  export interface Config {
53
65
  /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
54
- startup: { kind: string; sessionId?: string }
66
+ startup: { kind: string; sessionId?: string; mode?: string }
55
67
  }
56
68
 
57
69
  export const Config: z<Config> = z.object({
58
70
  startup: z.object({
59
71
  kind: z.string().required(),
60
72
  sessionId: z.string(),
73
+ mode: z.string(),
61
74
  }),
62
75
  })
63
76
 
@@ -93,6 +106,8 @@ function gitBranch(cwd: string): string {
93
106
  interface Target {
94
107
  sessionId: string
95
108
  resume: boolean
109
+ mode?: string
110
+ cwd?: string
96
111
  }
97
112
 
98
113
  /**
@@ -104,8 +119,8 @@ interface Target {
104
119
  * @throws with a user-facing message when the flags name nothing resolvable.
105
120
  */
106
121
  async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
107
- if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
108
- if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
122
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
123
+ if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
109
124
  if (persistence === undefined) {
110
125
  throw new Error('cannot resolve the requested session: session persistence is not configured')
111
126
  }
@@ -149,7 +164,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
149
164
  /** The runner's connection between the React app and the process side. */
150
165
  interface AppBridge {
151
166
  /** Post one local notice line (feedback the transcript does not carry). */
152
- notify(text: string): void
167
+ notify(text: string, tone?: NoticeTone): void
153
168
  }
154
169
 
155
170
  /**
@@ -168,91 +183,123 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
168
183
  const defaultModel = ctx.get('agentDefaultModel')
169
184
  const sessions = ctx.get('sessions')
170
185
  const persistence = ctx.get('sessionPersistence')
186
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
171
187
  // Early process shutdown can dispose the tree while settlement is pending.
172
188
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
173
189
 
174
190
  const cwd = process.cwd()
175
- const target = await resolveTarget(startup, persistence, cwd)
176
-
177
191
  const defaults = defaultModel.currentSelection()
178
- let picked: ModelSelection | undefined
179
- let session: Session
180
- let agent: Agent
181
- if (target.resume) {
182
- const resumed = await agents.resume({
183
- resumeSessionId: SessionId(target.sessionId),
184
- agentOptions: { provider: defaults.provider, model: defaults.model },
185
- setup: (agentCtx) => {
186
- // The getter order mirrors the web host's resume selection: an
187
- // in-process switch wins, then the session's own last logged request
188
- // header, then the deployment default. Without this, a resumed
189
- // session's first request would silently fall back to the default.
190
- const selection: ModelSelectionRef = {
191
- get current(): ModelSelection | undefined {
192
- if (picked !== undefined) return picked
193
- const logged = agentCtx.agent?.session.requestHeader()?.config
194
- if (logged !== undefined) {
195
- return {
196
- provider: logged.provider,
197
- model: logged.model,
198
- ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
199
- }
192
+ const presets = agentPresetsFrom(ctx)
193
+ if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
194
+
195
+ // A bare fresh launch stays transient: no Agent or session is composed, and
196
+ // nothing is persisted, until the user's first real input. Explicit flags
197
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
198
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
199
+
200
+ interface ActiveSession {
201
+ handle: AgentHandle
202
+ agent: Agent
203
+ session: Session
204
+ store: ReturnType<typeof createTranscriptStore>
205
+ mentions: MentionsApi
206
+ mode: string
207
+ selection: { picked?: ModelSelection }
208
+ resumed: boolean
209
+ }
210
+
211
+ /** Prepare a complete next session before disturbing the currently visible one. */
212
+ const prepare = async (next: Target): Promise<ActiveSession> => {
213
+ const nextCwd = next.cwd ?? cwd
214
+ const selectionState: { picked?: ModelSelection } = {}
215
+ let mode = next.mode
216
+ if (!next.resume) mode = (await presets.resolve(mode)).id
217
+ const setup = async (agentCtx: Context): Promise<void> => {
218
+ const sessionPreset = next.resume
219
+ ? resolvePreset(agentCtx.agent!.session)
220
+ : mode
221
+ const mounted = await presets.mount(agentCtx, sessionPreset)
222
+ mode = mounted.id
223
+ const selection: ModelSelectionRef = {
224
+ get current(): ModelSelection | undefined {
225
+ if (selectionState.picked !== undefined) return selectionState.picked
226
+ const logged = agentCtx.agent?.session.requestHeader()?.config
227
+ if (logged !== undefined) {
228
+ return {
229
+ provider: logged.provider,
230
+ model: logged.model,
231
+ ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
200
232
  }
201
- return defaults
202
- },
203
- set current(next: ModelSelection | undefined) {
204
- picked = next
205
- },
206
- assembled: undefined,
207
- }
208
- installModelSelection(agentCtx, selection)
209
- },
210
- })
211
- agent = resumed.agent
212
- session = agent.session
213
- } else {
214
- const created = await agents.create({
215
- sessionId: SessionId(target.sessionId),
216
- meta: { cwd },
217
- agentOptions: { provider: defaults.provider, model: defaults.model },
218
- setup: (agentCtx) => {
219
- // Same getter shape as resume, minus the logged-header arm (a fresh
220
- // session has no request header yet).
221
- const selection: ModelSelectionRef = {
222
- get current(): ModelSelection | undefined {
223
- return picked ?? defaults
224
- },
225
- set current(next: ModelSelection | undefined) {
226
- picked = next
227
- },
228
- assembled: undefined,
229
- }
230
- installModelSelection(agentCtx, selection)
231
- },
232
- })
233
- agent = created.agent
234
- session = agent.session
233
+ }
234
+ return defaults
235
+ },
236
+ set current(value: ModelSelection | undefined) { selectionState.picked = value },
237
+ assembled: undefined,
238
+ }
239
+ installModelSelection(agentCtx, selection)
240
+ }
241
+ const handle = next.resume
242
+ ? await agents.resume({
243
+ resumeSessionId: SessionId(next.sessionId),
244
+ agentOptions: { provider: defaults.provider, model: defaults.model },
245
+ setup,
246
+ })
247
+ : await agents.create({
248
+ sessionId: SessionId(next.sessionId),
249
+ meta: { cwd: nextCwd, agentPreset: mode },
250
+ agentOptions: { provider: defaults.provider, model: defaults.model },
251
+ setup,
252
+ })
253
+ const session = handle.agent.session
254
+ const sessionCwd = session.header.cwd ?? nextCwd
255
+ return {
256
+ handle,
257
+ agent: handle.agent,
258
+ session,
259
+ store: createTranscriptStore(session.events),
260
+ mentions: createMentions(ctx, handle.agent, sessionCwd),
261
+ mode: mode ?? 'standard',
262
+ selection: selectionState,
263
+ resumed: next.resume,
264
+ }
265
+ }
266
+
267
+ let active: ActiveSession | undefined
268
+ let agent: Agent | undefined
269
+ let session: Session | undefined
270
+ let store: TranscriptStore = createTranscriptStore()
271
+ let mentions: MentionsApi | undefined
272
+
273
+ if (!lazy) {
274
+ const target = await resolveTarget(startup, persistence, cwd)
275
+ const prepared = await prepare(target)
276
+ active = prepared
277
+ agent = prepared.agent
278
+ session = prepared.session
279
+ store = prepared.store
280
+ mentions = prepared.mentions
235
281
  }
236
282
 
237
283
  // Seed the transcript from the full session log: constructor seeds never
238
- // fire on `session/event`, so a resumed session paints its history once,
239
- // here, before the first render.
240
- const store = createTranscriptStore(session.events)
284
+ // fire on `session/event`, so a resumed session paints its history once
285
+ // before the first render. The handler reads the current session/store, so
286
+ // the deferred first session of a bare launch is covered by the same feed.
241
287
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
242
- if (subject.id === session.id) store.apply(event)
288
+ if (session !== undefined && subject.id === session.id) store.apply(event)
243
289
  })
244
290
 
245
291
  const commands: CommandsView = watchCommands(ctx)
246
- commands.setAgent(agent)
292
+ if (agent !== undefined) commands.setAgent(agent)
247
293
 
248
294
  const skills: SkillsView = watchSkills(ctx)
249
- skills.setAgent(agent)
295
+ if (agent !== undefined) skills.setAgent(agent)
250
296
 
251
297
  // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
252
- // claimed, every other ask falls through to the fail-closed waterfall.
298
+ // claimed, every other ask falls through to the fail-closed waterfall. The
299
+ // owner predicate is empty until the first session exists.
253
300
  const approval: ApprovalStore = mountApprovalAnswerer(
254
301
  ctx,
255
- candidate => candidate.id === agent.id,
302
+ candidate => agent !== undefined && candidate.id === agent.id,
256
303
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
257
304
  )
258
305
 
@@ -261,14 +308,75 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
261
308
  // through this same pipe.
262
309
  const questions: QuestionStore = mountQuestionProvider(ctx)
263
310
 
264
- // @mention support: workspace file scan plus the opt-in session-reference
265
- // service (the patch mounts it); submission expands session mentions.
266
- const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
267
-
268
311
  // The bridge the React app registers on mount: local notices from the
269
312
  // process side (unknown commands, switch confirmations, cancels).
270
313
  const bridge: AppBridge = { notify: () => {} }
271
314
 
315
+ // /statusline persistence: one user-level JSON file under the DSH home.
316
+ // Missing file means defaults; a corrupt file degrades to defaults with a
317
+ // surfaced warning (the customization is user-authored, never silent).
318
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
319
+ let statuslineWarning: string | undefined
320
+ let statuslineItems: readonly string[] = []
321
+ try {
322
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
323
+ } catch (error) {
324
+ statuslineItems = parseStatuslineItems(undefined)
325
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
326
+ statuslineWarning = error instanceof Error ? error.message : String(error)
327
+ }
328
+ }
329
+ const saveStatusline = (items: readonly string[]): void => {
330
+ statuslineItems = [...items]
331
+ // The config directory may not exist on a first save; create it before
332
+ // the write so a fresh install persists customizations.
333
+ void mkdir(dirname(statuslinePath), { recursive: true })
334
+ .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
335
+ .catch((writeError: unknown) => {
336
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
337
+ })
338
+ }
339
+
340
+ // Global input recall (Codex composer-history contract): one JSONL file
341
+ // under the DSH home. A missing file means an empty history; unreadable or
342
+ // corrupt content degrades to the valid lines it could parse, silently —
343
+ // recall is a convenience surface, never a gate.
344
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
345
+ let inputHistory: readonly string[] = []
346
+ try {
347
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
348
+ } catch {
349
+ inputHistory = []
350
+ }
351
+ const recordHistory = (text: string): void => {
352
+ if (text === '') return
353
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
354
+ // A missing file on the first save is not an error: start from empty.
355
+ let current = ''
356
+ try {
357
+ current = readFileSync(historyPath, 'utf8')
358
+ } catch {
359
+ current = ''
360
+ }
361
+ void mkdir(dirname(historyPath), { recursive: true })
362
+ .then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
363
+ .catch((writeError: unknown) => {
364
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
365
+ })
366
+ }
367
+
368
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
369
+ const cancelQueued = (messageId: string): void => {
370
+ if (agent === undefined) return
371
+ try {
372
+ if (agent.inbox.remove(MessageId(messageId))) {
373
+ bridge.notify('queued message cancelled')
374
+ }
375
+ } catch (error: unknown) {
376
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
377
+ }
378
+ }
379
+
272
380
  // The mount handle lives in a box: quit closes over it, while the mount
273
381
  // itself is created after quit (the App element needs quit as a prop).
274
382
  const mountRef: { current?: TuiMount } = {}
@@ -276,44 +384,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
276
384
  const quit = (): void => {
277
385
  if (quitting) return
278
386
  quitting = true
387
+ switchQueue.cancel()
279
388
  off()
280
389
  mountRef.current?.unmount()
281
- void sessions.flush(session)
390
+ // A bare launch that exits before the first input has no session: exit
391
+ // cleanly without flushing or disposing anything.
392
+ const currentSession = session
393
+ const currentActive = active
394
+ if (currentSession === undefined || currentActive === undefined) {
395
+ io.exit(0)
396
+ return
397
+ }
398
+ void sessions.flush(currentSession)
282
399
  .catch((flushError: unknown) => {
283
400
  // The session log already carries every durable event; a failed flush
284
401
  // must not trap the user in a dead terminal, so report and still exit.
285
402
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
286
403
  })
404
+ .then(() => currentActive.handle.dispose())
405
+ .catch((disposeError: unknown) => {
406
+ internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
407
+ })
287
408
  .then(() => { io.exit(0) })
288
409
  }
289
410
 
290
411
  /** Run one slash line through the command registry (closed namespace). */
291
412
  const runSlash = (line: string): void => {
413
+ const currentAgent = agent
414
+ if (currentAgent === undefined) return
415
+ if (line.startsWith('/mode ')) {
416
+ void switchModeAction(line.slice(6).trim())
417
+ return
418
+ }
419
+ if (line.startsWith('/resume ')) {
420
+ requestResume(line.slice(8).trim())
421
+ return
422
+ }
292
423
  const registry = ctx.get('commands')
293
424
  if (registry === undefined) {
294
- bridge.notify('no command registry is mounted in this composition')
425
+ bridge.notify('no command registry is mounted in this composition', 'error')
295
426
  return
296
427
  }
297
428
  const controller = new AbortController()
298
- void registry.execute(agent, line, controller.signal).then((execution) => {
429
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
299
430
  if (execution === undefined) {
300
431
  // No command owns this line: send it verbatim so a user-invocable
301
432
  // skill gesture (`/skill-name`) reaches the host's tool-skill
302
433
  // pre-step injection — the web composer's same fall-through.
303
- agent.followup(createUserMessage({
304
- content: [{ type: 'text', text: line }],
305
- source: { kind: 'user' },
306
- }))
434
+ try {
435
+ currentAgent.followup(createUserMessage({
436
+ content: [{ type: 'text', text: line }],
437
+ source: { kind: 'user' },
438
+ }))
439
+ } catch (error: unknown) {
440
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
441
+ }
307
442
  }
308
443
  }, (error: unknown) => {
309
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
444
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
310
445
  })
311
446
  }
312
447
 
313
- /** Deliver one readable line to the agent, expanding session mentions first. */
314
- const send = (text: string, mode: 'followup' | 'steer'): void => {
315
- const line = text.trim()
316
- if (line === '') return
448
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
449
+ const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
450
+ const currentAgent = agent!
451
+ const currentMentions = mentions!
317
452
  // The command registry is a closed namespace: slash lines run out of
318
453
  // band and never reach the model through this path (steering keeps the
319
454
  // registry out of the inbox, so slash lines steer as literal text).
@@ -321,27 +456,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
321
456
  runSlash(line)
322
457
  return
323
458
  }
324
- let parsed: ReturnType<typeof mentions.parse>
459
+ let parsed: ReturnType<MentionsApi['parse']>
325
460
  try {
326
- parsed = mentions.parse(line)
461
+ parsed = currentMentions.parse(line)
327
462
  } catch (error: unknown) {
328
- bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`)
463
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
329
464
  return
330
465
  }
331
466
  const deliver = (readable: string, context?: UserMessage): void => {
332
467
  // Session snapshots ride the inbox as model-facing context ahead of
333
468
  // the readable message (upstream README wiring: inject before the
334
469
  // followup/steer that wakes the driver).
335
- if (context !== undefined) agent.inject(context)
336
- const message = createUserMessage({
337
- content: [{ type: 'text', text: readable }],
338
- source: { kind: 'user' },
339
- })
340
- if (mode === 'steer') {
341
- agent.steer(message)
342
- bridge.notify('steering queued the next step sees it')
343
- } else {
344
- agent.followup(message)
470
+ try {
471
+ if (context !== undefined) currentAgent.inject(context)
472
+ const message = createUserMessage({
473
+ content: [{ type: 'text', text: readable }],
474
+ source: { kind: 'user' },
475
+ })
476
+ if (mode === 'steer') {
477
+ // The queued message is visible as a pending transcript row (the
478
+ // web queue-mirror contract); no notice noise on the happy path.
479
+ currentAgent.steer(message)
480
+ } else {
481
+ currentAgent.followup(message)
482
+ }
483
+ } catch (error: unknown) {
484
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
345
485
  }
346
486
  }
347
487
  if (parsed.references.length === 0) {
@@ -349,12 +489,62 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
349
489
  return
350
490
  }
351
491
  const controller = new AbortController()
352
- void mentions.prepare(parsed, controller.signal).then((prepared) => {
492
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
353
493
  deliver(prepared.text, prepared.additionalContext)
354
494
  }, (error: unknown) => {
355
495
  if (controller.signal.aborted) return
356
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`)
496
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
497
+ })
498
+ }
499
+
500
+ // Deferred first-session creation for a bare launch: the session is composed
501
+ // only when the user submits real input (or /new), and every line that
502
+ // arrives during creation is delivered in order afterwards. A creation
503
+ // failure reports and clears the queue, leaving the transient state ready
504
+ // for the next attempt.
505
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
506
+ let creating: Promise<void> | undefined
507
+ const ensureSession = (mode?: string): void => {
508
+ if (creating !== undefined) return
509
+ const attempt = (async () => {
510
+ const next = await prepare({
511
+ sessionId: `session-${randomUUID()}`,
512
+ resume: false,
513
+ ...(mode === undefined ? {} : { mode }),
514
+ })
515
+ if (quitting) {
516
+ void next.handle.dispose().catch(() => {})
517
+ return
518
+ }
519
+ active = next
520
+ agent = next.agent
521
+ session = next.session
522
+ store = next.store
523
+ mentions = next.mentions
524
+ commands.setAgent(agent)
525
+ skills.setAgent(agent)
526
+ renderCurrent()
527
+ const queued = pendingInputs.splice(0)
528
+ for (const item of queued) deliverLine(item.text, item.mode)
529
+ })().catch((error: unknown) => {
530
+ pendingInputs.length = 0
531
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
532
+ }).finally(() => {
533
+ creating = undefined
357
534
  })
535
+ creating = attempt
536
+ }
537
+
538
+ /** Deliver one readable line to the agent, expanding session mentions first. */
539
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
540
+ const line = text.trim()
541
+ if (line === '') return
542
+ if (session === undefined) {
543
+ pendingInputs.push({ text: line, mode })
544
+ ensureSession()
545
+ return
546
+ }
547
+ deliverLine(line, mode)
358
548
  }
359
549
 
360
550
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
@@ -373,10 +563,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
373
563
 
374
564
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
375
565
  const interrupt = (): boolean => {
376
- if (agent.status !== 'running') return false
377
- agent.cancel({ kind: 'user' })
378
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
379
- return true
566
+ if (agent === undefined || agent.status !== 'running') return false
567
+ try {
568
+ agent.cancel({ kind: 'user' })
569
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
570
+ return true
571
+ } catch (error: unknown) {
572
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
573
+ return false
574
+ }
380
575
  }
381
576
 
382
577
  /**
@@ -385,6 +580,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
385
580
  * custom knob state wraps to the first declared preset.
386
581
  */
387
582
  const cyclePermission = (): string => {
583
+ if (session === undefined) throw new Error('no session yet — submit a message to start')
388
584
  const service = ctx.get('permissionPresets') as
389
585
  | {
390
586
  names: readonly string[]
@@ -393,19 +589,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
393
589
  }
394
590
  | undefined
395
591
  if (service === undefined || service.names.length === 0) {
396
- bridge.notify('permission presets are not mounted in this composition')
592
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
397
593
  return ''
398
594
  }
399
595
  const at = service.names.indexOf(service.current(session.events))
400
596
  const next = service.names[(at + 1) % service.names.length] ?? ''
401
597
  if (next === '') return ''
402
- service.set(session, next)
403
- return next
598
+ try {
599
+ service.set(session, next)
600
+ return next
601
+ } catch (error: unknown) {
602
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
603
+ return ''
604
+ }
404
605
  }
405
606
 
406
607
  /** Apply one /model selection: takes effect from the next assembled step. */
407
608
  const selectModel = (row: ModelRow): string => {
408
- picked = { provider: row.provider, model: row.model }
609
+ if (active === undefined) throw new Error('no session yet — submit a message to start')
610
+ active.selection.picked = { provider: row.provider, model: row.model }
409
611
  return `${row.provider}/${row.model}`
410
612
  }
411
613
 
@@ -415,19 +617,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
415
617
  * workspace; an absolute or cwd-relative argument overrides it.
416
618
  */
417
619
  const exportTranscript = async (argument: string): Promise<void> => {
620
+ if (session === undefined) {
621
+ bridge.notify('no session yet — submit a message to start', 'warning')
622
+ return
623
+ }
418
624
  const wanted = argument.trim()
625
+ const sessionCwd = session.header.cwd ?? cwd
419
626
  const defaultName = `dsh-session-${session.id.slice(-8)}.md`
420
627
  const target = wanted === ''
421
- ? join(cwd, defaultName)
628
+ ? join(sessionCwd, defaultName)
422
629
  : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
423
630
  ? wanted
424
- : join(cwd, wanted)
631
+ : join(sessionCwd, wanted)
425
632
  const markdown = buildExportMarkdown(store.getView(), session.id)
426
633
  try {
427
634
  await writeFileAsync(target, `${markdown}\n`, 'utf8')
428
635
  bridge.notify(`exported to ${target}`)
429
636
  } catch (error: unknown) {
430
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`)
637
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
431
638
  }
432
639
  }
433
640
 
@@ -439,6 +646,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
439
646
  const renameTitle = (argument: string): string => {
440
647
  const title = argument.trim()
441
648
  if (title === '') return 'usage: /title <text>'
649
+ if (session === undefined) return 'no session yet — submit a message to start'
442
650
  const service = ctx.get('sessionTitle')
443
651
  if (service === undefined) return 'session titles are unavailable in this profile'
444
652
  try {
@@ -449,35 +657,222 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
449
657
  }
450
658
  }
451
659
 
452
- const initialModel = store.getView().model !== ''
453
- ? store.getView().model
454
- : `${defaults.provider}/${defaults.model}`
455
-
456
- mountRef.current = io.mount(createElement(App, {
457
- store,
458
- approval,
459
- questions,
460
- commands,
461
- skills,
462
- model: initialModel,
463
- cwd: basename(cwd),
464
- branch: gitBranch(cwd),
465
- sessionId: session.id.slice(-8),
466
- resumed: target.resume,
467
- dispatch,
468
- steer,
469
- interrupt,
470
- quit,
471
- loadModels: () => loadModelDirectory(ctx),
472
- loadMentions: mentions.candidates,
473
- cyclePermission,
474
- selectModel,
475
- exportTranscript,
476
- renameTitle,
477
- onBridgeReady: (instance: AppBridge) => {
478
- bridge.notify = instance.notify
479
- },
480
- }))
660
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
661
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
662
+ const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
663
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
664
+ // navigation/filter changes trigger a fresh, cancellable observation.
665
+ const page = projected.slice(0, 32)
666
+ if (page.length === 0) return projected
667
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
668
+ return mergeSessionTitles(projected, observations)
669
+ }
670
+
671
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
672
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
673
+ const snapshot = await sessionQuery.readSession(id, signal)
674
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
675
+ }
676
+
677
+ const switchModeAction = async (id: string): Promise<string> => {
678
+ if (id === '') throw new Error('usage: /mode <preset>')
679
+ const currentAgent = agent
680
+ const currentActive = active
681
+ if (currentAgent === undefined || currentActive === undefined) {
682
+ throw new Error('no session yet — submit a message to start')
683
+ }
684
+ const preset = await switchPreset(presets, currentAgent, id)
685
+ currentActive.mode = preset.id
686
+ commands.setAgent(currentAgent)
687
+ skills.setAgent(currentAgent)
688
+ renderCurrent()
689
+ return preset.id
690
+ }
691
+
692
+ interface PendingSwitch { readonly target: Target; readonly label: string }
693
+
694
+ const activate = async (nextTarget: Target): Promise<void> => {
695
+ const previous = active
696
+ const next = await prepare(nextTarget)
697
+ active = next
698
+ agent = next.agent
699
+ session = next.session
700
+ store = next.store
701
+ mentions = next.mentions
702
+ commands.setAgent(agent)
703
+ skills.setAgent(agent)
704
+ try {
705
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
706
+ renderCurrent()
707
+ } catch (error: unknown) {
708
+ active = previous
709
+ agent = previous?.agent
710
+ session = previous?.session
711
+ store = previous === undefined ? createTranscriptStore() : previous.store
712
+ mentions = previous?.mentions
713
+ if (agent !== undefined) commands.setAgent(agent)
714
+ if (agent !== undefined) skills.setAgent(agent)
715
+ await next.handle.dispose()
716
+ renderCurrent()
717
+ throw error
718
+ }
719
+ // No previous session (a bare launch switched straight into a resume):
720
+ // nothing to flush or dispose, so just confirm the activation.
721
+ if (previous === undefined) {
722
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
723
+ return
724
+ }
725
+ let cleanupWarning: string | undefined
726
+ try {
727
+ await sessions.flush(previous.session)
728
+ } catch (error: unknown) {
729
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
730
+ }
731
+ try {
732
+ await previous.handle.dispose()
733
+ } catch (error: unknown) {
734
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
735
+ }
736
+ bridge.notify(cleanupWarning === undefined
737
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
738
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
739
+ cleanupWarning === undefined ? 'info' : 'warning')
740
+ }
741
+
742
+ const switchQueue = new SessionSwitchQueue<PendingSwitch>(
743
+ async request => { if (!quitting) await activate(request.target) },
744
+ error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
745
+ )
746
+
747
+ const requestSwitch = (request: PendingSwitch): void => {
748
+ if (session === undefined) {
749
+ // No session yet (a bare launch using /resume before any input): activate
750
+ // the target directly — there is no running turn to wait on and nothing
751
+ // to flush.
752
+ void activate(request.target).catch((error: unknown) => {
753
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
754
+ })
755
+ return
756
+ }
757
+ if (request.target.sessionId === session.id) {
758
+ bridge.notify('that session is already active', 'warning')
759
+ return
760
+ }
761
+ const outcome = switchQueue.request(agent!, request)
762
+ if (outcome === 'queued') {
763
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
764
+ }
765
+ }
766
+
767
+ const resolveResumeId = async (wanted: string): Promise<string> => {
768
+ if (wanted === '') throw new Error('usage: /resume <id|prefix>')
769
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
770
+ const records = await sessionQuery.listSessions()
771
+ const exact = records.filter(record => record.header.id === wanted)
772
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
773
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
774
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
775
+ if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
776
+ throw new Error('subagent conversations are read-only in /resume; resume a root session')
777
+ }
778
+ if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
779
+ throw new Error('that session is already live in another owner')
780
+ }
781
+ return matches[0]!.header.id
782
+ }
783
+
784
+ const requestResume = (wanted: string): void => {
785
+ void resolveResumeId(wanted).then(id => {
786
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
787
+ }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
788
+ }
789
+
790
+ const createSession = (mode?: string): void => {
791
+ // /new before any input is the first-session creation itself, not a switch.
792
+ if (session === undefined) {
793
+ ensureSession(mode)
794
+ return
795
+ }
796
+ const nextCwd = session.header.cwd ?? cwd
797
+ const id = `session-${randomUUID()}`
798
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
799
+ }
800
+
801
+ const switchSession = (row: SessionRow): void => {
802
+ if (!row.resumable) {
803
+ bridge.notify('subagent conversations are read-only', 'warning')
804
+ return
805
+ }
806
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
807
+ }
808
+
809
+ const cancelSessionSwitch = (): boolean => {
810
+ return switchQueue.cancel()
811
+ }
812
+
813
+ const appElement = (): ReturnType<typeof createElement> => {
814
+ // A bare launch mounts with placeholder facts until the first input
815
+ // composes a real session: empty session id/mode, the deployment default
816
+ // model, and the working directory's basename. `status.ts` drops empty
817
+ // mode/sessionId, so the bar renders only the identity it actually has.
818
+ const sessionCwd = session?.header.cwd ?? cwd
819
+ const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
820
+ return createElement(App, {
821
+ key: session?.id ?? 'pending',
822
+ store,
823
+ approval,
824
+ questions,
825
+ commands,
826
+ skills,
827
+ model,
828
+ cwd: basename(sessionCwd),
829
+ workspaceRoot: sessionCwd,
830
+ branch: gitBranch(sessionCwd),
831
+ sessionId: session === undefined ? '' : session.id.slice(-8),
832
+ resumed: active?.resumed ?? false,
833
+ mode: active?.mode ?? '',
834
+ dispatch,
835
+ steer,
836
+ interrupt,
837
+ quit,
838
+ loadModels: () => loadModelDirectory(ctx),
839
+ loadMentions: mentions === undefined
840
+ ? () => Promise.resolve<readonly MentionCandidate[]>([])
841
+ : mentions.candidates,
842
+ cyclePermission,
843
+ selectModel,
844
+ exportTranscript,
845
+ renameTitle,
846
+ loadPresets: () => presets.list(),
847
+ switchMode: switchModeAction,
848
+ createSession,
849
+ loadSessions,
850
+ loadSessionTranscript,
851
+ switchSession,
852
+ cancelSessionSwitch,
853
+ loadPlugins: () => listPluginRows(ctx),
854
+ statusline: statuslineItems,
855
+ saveStatusline,
856
+ history: inputHistory,
857
+ recordHistory,
858
+ cancelQueued,
859
+ onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
860
+ })
861
+ }
862
+
863
+ const renderCurrent = (): void => {
864
+ mountRef.current?.rerender(appElement())
865
+ }
866
+
867
+ mountRef.current = io.mount(appElement())
868
+
869
+ // A corrupt statusline config must not vanish silently: surface it once
870
+ // the notice channel is live, after the first frame settles.
871
+ if (statuslineWarning !== undefined) {
872
+ setTimeout(() => {
873
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
874
+ }, 50)
875
+ }
481
876
  }
482
877
 
483
878
  /**
@@ -492,8 +887,8 @@ export function apply(ctx: Context, config: Config): void {
492
887
  : config.startup.kind === 'latest'
493
888
  ? { kind: 'latest' }
494
889
  : config.startup.kind === 'named' && config.startup.sessionId !== undefined
495
- ? { kind: 'named', sessionId: config.startup.sessionId }
496
- : { kind: 'fresh' }
890
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
891
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
497
892
  // Read through the global service store, not the property proxy: appExit is
498
893
  // an optional host value, never an injected dependency.
499
894
  const exit = ctx.get('appExit')