dsh-code 0.6.1 → 0.8.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 (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
package/src/index.ts CHANGED
@@ -1,905 +1,1207 @@
1
- /**
2
- * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
- * rides over dsh-base without Host, HTTP, or browser plugins; this runner
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.
8
- *
9
- * @module @deepseek-ai/dsh-code
10
- */
11
-
12
- import { randomUUID } from 'node:crypto'
13
- import { readFileSync } from 'node:fs'
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
- import { createElement } from 'react'
18
- import type { Context } from '@deepseek-ai/cordis'
19
- import z from '@deepseek-ai/schemastery'
20
- import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
- import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
- import type {} from '@deepseek-ai/dsh-agent-default-model'
23
- import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
24
- import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
25
- import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
26
- // Type-only: carries the ctx.sessionTitle service merge for /title.
27
- import type {} from '@deepseek-ai/dsh-session-title'
28
- // Empty type imports carry the loader Context merge for the settlement await
29
- // and the cmdline Context merge for the appExit host value.
30
- import type {} from '@deepseek-ai/cordis-plugin-loader'
31
- import type {} from '@deepseek-ai/dsh-cmdline'
32
- import { App, type NoticeTone } from './app.ts'
33
- import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
34
- import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
35
- import { internals, type TuiMount } from './internals.ts'
36
- import { loadModelDirectory, type ModelRow } from './models.ts'
37
- import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
38
- import { mountQuestionProvider, type QuestionStore } from './questions.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'
42
- import { watchSkills, type SkillsView } from './skills.ts'
43
- import { toolArgumentsPreview } from './render/tool-preview.ts'
44
- import { buildExportMarkdown } from './render/export.ts'
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'
56
-
57
- /** Stable Cordis plugin name. */
58
- export const name = 'tui-runner'
59
-
60
- /** Core services required before the interactive session can start. */
61
- export const inject = ['agentDefaultModel', 'agents', 'sessions']
62
-
63
- /** Plugin config: the startup resolved from this app's injected provider service. */
64
- export interface Config {
65
- /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
66
- startup: { kind: string; sessionId?: string; mode?: string }
67
- }
68
-
69
- export const Config: z<Config> = z.object({
70
- startup: z.object({
71
- kind: z.string().required(),
72
- sessionId: z.string(),
73
- mode: z.string(),
74
- }),
75
- })
76
-
77
- /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
78
- interface TuiIo {
79
- mount: typeof internals.mount
80
- exit(code: number): void
81
- }
82
-
83
- /** Report an unexpected direct-driver failure and request a failing exit. */
84
- function fail(io: TuiIo, error: unknown): void {
85
- internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
86
- io.exit(1)
87
- }
88
-
89
- /**
90
- * Resolve the working directory's git branch for the status line.
91
- * @param cwd - the session's working directory.
92
- * @returns the branch name, or '' outside a repository or on a detached HEAD.
93
- */
94
- function gitBranch(cwd: string): string {
95
- try {
96
- const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
97
- return ref?.[1] ?? ''
98
- } catch {
99
- // Only the single HEAD read is attempted, so the sole reachable failure is
100
- // a missing repository (or unreadable HEAD file): the branch group drops out.
101
- return ''
102
- }
103
- }
104
-
105
- /** The session identity this invocation will run, plus whether it is resumed. */
106
- interface Target {
107
- sessionId: string
108
- resume: boolean
109
- mode?: string
110
- cwd?: string
111
- }
112
-
113
- /**
114
- * Resolve the invocation's target session against the persisted headers.
115
- * @param startup - the parsed startup flags.
116
- * @param persistence - the persistence service; required for resume/latest.
117
- * @param cwd - the working directory `--continue` filters by.
118
- * @returns the target identity.
119
- * @throws with a user-facing message when the flags name nothing resolvable.
120
- */
121
- async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
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 }
124
- if (persistence === undefined) {
125
- throw new Error('cannot resolve the requested session: session persistence is not configured')
126
- }
127
- const headers: readonly SessionHeader[] = await persistence.list()
128
- if (startup.kind === 'resume') {
129
- const wanted = startup.sessionId
130
- const exact = headers.filter(header => header.id === wanted)
131
- const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
132
- if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
133
- if (matches.length > 1) {
134
- throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
135
- }
136
- return { sessionId: matches[0]!.id, resume: true }
137
- }
138
- // --continue: the newest persisted session whose header pins this cwd.
139
- const local = headers
140
- .filter(header => header.cwd === cwd)
141
- .sort((left, right) => right.createdAt - left.createdAt)
142
- if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
143
- return { sessionId: local[0]!.id, resume: true }
144
- }
145
-
146
- /**
147
- * Resolve a bounded command preview for one pending approval: the request
148
- * contract carries no arguments, so the bar self-serves from the transcript
149
- * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
150
- * @param events - the transcript entries to search.
151
- * @param callId - the tool call the question is about, when the asker had one.
152
- * @param toolName - the tool the question is about.
153
- * @returns a bounded preview line, '' when nothing useful resolves.
154
- */
155
- function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
156
- if (callId === undefined) return ''
157
- const entry = events.find(candidate =>
158
- candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
159
- if (entry === undefined) return ''
160
- const args = (entry as { arguments?: string }).arguments ?? ''
161
- return toolArgumentsPreview(args, toolName)
162
- }
163
-
164
- /** The runner's connection between the React app and the process side. */
165
- interface AppBridge {
166
- /** Post one local notice line (feedback the transcript does not carry). */
167
- notify(text: string, tone?: NoticeTone): void
168
- }
169
-
170
- /**
171
- * Run the interactive terminal session: resolve the target session, create or
172
- * resume one Agent, mount the app, and keep the process alive until the user
173
- * quits.
174
- * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
175
- * @param startup - the parsed invocation flags.
176
- * @param io - process-facing effects.
177
- */
178
- async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
179
- // Loader siblings mount concurrently. Await the complete application before
180
- // creating an Agent so its scoped tools and adapters are not half-composed.
181
- await ctx.get('loader')?.await()
182
- const agents = ctx.get('agents')
183
- const defaultModel = ctx.get('agentDefaultModel')
184
- const sessions = ctx.get('sessions')
185
- const persistence = ctx.get('sessionPersistence')
186
- const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
187
- // Early process shutdown can dispose the tree while settlement is pending.
188
- if (agents === undefined || defaultModel === undefined || sessions === undefined) return
189
-
190
- const cwd = process.cwd()
191
- const defaults = defaultModel.currentSelection()
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 },
232
- }
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
281
- }
282
-
283
- // Seed the transcript from the full session log: constructor seeds never
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.
287
- const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
288
- if (session !== undefined && subject.id === session.id) store.apply(event)
289
- })
290
-
291
- const commands: CommandsView = watchCommands(ctx)
292
- if (agent !== undefined) commands.setAgent(agent)
293
-
294
- const skills: SkillsView = watchSkills(ctx)
295
- if (agent !== undefined) skills.setAgent(agent)
296
-
297
- // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
298
- // claimed, every other ask falls through to the fail-closed waterfall. The
299
- // owner predicate is empty until the first session exists.
300
- const approval: ApprovalStore = mountApprovalAnswerer(
301
- ctx,
302
- candidate => agent !== undefined && candidate.id === agent.id,
303
- request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
304
- )
305
-
306
- // ask_user_question provider: the single UI provider on the shared service,
307
- // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
308
- // through this same pipe.
309
- const questions: QuestionStore = mountQuestionProvider(ctx)
310
-
311
- // The bridge the React app registers on mount: local notices from the
312
- // process side (unknown commands, switch confirmations, cancels).
313
- const bridge: AppBridge = { notify: () => {} }
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
-
380
- // The mount handle lives in a box: quit closes over it, while the mount
381
- // itself is created after quit (the App element needs quit as a prop).
382
- const mountRef: { current?: TuiMount } = {}
383
- let quitting = false
384
- const quit = (): void => {
385
- if (quitting) return
386
- quitting = true
387
- switchQueue.cancel()
388
- off()
389
- mountRef.current?.unmount()
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)
399
- .catch((flushError: unknown) => {
400
- // The session log already carries every durable event; a failed flush
401
- // must not trap the user in a dead terminal, so report and still exit.
402
- internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
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
- })
408
- .then(() => { io.exit(0) })
409
- }
410
-
411
- /** Run one slash line through the command registry (closed namespace). */
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
- }
423
- const registry = ctx.get('commands')
424
- if (registry === undefined) {
425
- bridge.notify('no command registry is mounted in this composition', 'error')
426
- return
427
- }
428
- const controller = new AbortController()
429
- void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
430
- if (execution === undefined) {
431
- // No command owns this line: send it verbatim so a user-invocable
432
- // skill gesture (`/skill-name`) reaches the host's tool-skill
433
- // pre-step injection the web composer's same fall-through.
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
- }
442
- }
443
- }, (error: unknown) => {
444
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
445
- })
446
- }
447
-
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!
452
- // The command registry is a closed namespace: slash lines run out of
453
- // band and never reach the model through this path (steering keeps the
454
- // registry out of the inbox, so slash lines steer as literal text).
455
- if (isSlashLine(line) && mode === 'followup') {
456
- runSlash(line)
457
- return
458
- }
459
- let parsed: ReturnType<MentionsApi['parse']>
460
- try {
461
- parsed = currentMentions.parse(line)
462
- } catch (error: unknown) {
463
- bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
464
- return
465
- }
466
- const deliver = (readable: string, context?: UserMessage): void => {
467
- // Session snapshots ride the inbox as model-facing context ahead of
468
- // the readable message (upstream README wiring: inject before the
469
- // followup/steer that wakes the driver).
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')
485
- }
486
- }
487
- if (parsed.references.length === 0) {
488
- deliver(parsed.text)
489
- return
490
- }
491
- const controller = new AbortController()
492
- void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
493
- deliver(prepared.text, prepared.additionalContext)
494
- }, (error: unknown) => {
495
- if (controller.signal.aborted) return
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
- // The App mounts with a placeholder key until the first input; the
527
- // key-change remount below must start from a clean screen or the ghost
528
- // static header stays visible above the new one (same source-backed
529
- // clear the session-switch path performs).
530
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
531
- renderCurrent()
532
- const queued = pendingInputs.splice(0)
533
- for (const item of queued) deliverLine(item.text, item.mode)
534
- })().catch((error: unknown) => {
535
- pendingInputs.length = 0
536
- bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
537
- }).finally(() => {
538
- creating = undefined
539
- })
540
- creating = attempt
541
- }
542
-
543
- /** Deliver one readable line to the agent, expanding session mentions first. */
544
- const send = (text: string, mode: 'followup' | 'steer'): void => {
545
- const line = text.trim()
546
- if (line === '') return
547
- if (session === undefined) {
548
- pendingInputs.push({ text: line, mode })
549
- ensureSession()
550
- return
551
- }
552
- deliverLine(line, mode)
553
- }
554
-
555
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
556
- const dispatch = (text: string): void => {
557
- send(text, 'followup')
558
- }
559
-
560
- /**
561
- * Submit steering: a running driver consumes the text at its next step
562
- * boundary (the inbox delivers between steps); an idle driver just starts
563
- * a turn, so this doubles as the busy-state submit path.
564
- */
565
- const steer = (text: string): void => {
566
- send(text, 'steer')
567
- }
568
-
569
- /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
570
- const interrupt = (): boolean => {
571
- if (agent === undefined || agent.status !== 'running') return false
572
- try {
573
- agent.cancel({ kind: 'user' })
574
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
575
- return true
576
- } catch (error: unknown) {
577
- bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
578
- return false
579
- }
580
- }
581
-
582
- /**
583
- * Cycle to the next permission preset (Shift+Tab, the Claude-Code
584
- * permission-mode convention mapped onto dsh presets). A session in a
585
- * custom knob state wraps to the first declared preset.
586
- */
587
- const cyclePermission = (): string => {
588
- if (session === undefined) throw new Error('no session yet — submit a message to start')
589
- const service = ctx.get('permissionPresets') as
590
- | {
591
- names: readonly string[]
592
- current(events: readonly SessionEvent[]): string
593
- set(target: Session, preset: string): void
594
- }
595
- | undefined
596
- if (service === undefined || service.names.length === 0) {
597
- bridge.notify('permission presets are not mounted in this composition', 'warning')
598
- return ''
599
- }
600
- const at = service.names.indexOf(service.current(session.events))
601
- const next = service.names[(at + 1) % service.names.length] ?? ''
602
- if (next === '') return ''
603
- try {
604
- service.set(session, next)
605
- return next
606
- } catch (error: unknown) {
607
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
608
- return ''
609
- }
610
- }
611
-
612
- /** Apply one /model selection: takes effect from the next assembled step. */
613
- const selectModel = (row: ModelRow): string => {
614
- if (active === undefined) throw new Error('no session yet submit a message to start')
615
- active.selection.picked = { provider: row.provider, model: row.model }
616
- return `${row.provider}/${row.model}`
617
- }
618
-
619
- /**
620
- * Export the folded transcript to a markdown file (/export). The default
621
- * target sits beside the session's cwd so the file lands in the user's
622
- * workspace; an absolute or cwd-relative argument overrides it.
623
- */
624
- const exportTranscript = async (argument: string): Promise<void> => {
625
- if (session === undefined) {
626
- bridge.notify('no session yet — submit a message to start', 'warning')
627
- return
628
- }
629
- const wanted = argument.trim()
630
- const sessionCwd = session.header.cwd ?? cwd
631
- const defaultName = `dsh-session-${session.id.slice(-8)}.md`
632
- const target = wanted === ''
633
- ? join(sessionCwd, defaultName)
634
- : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
635
- ? wanted
636
- : join(sessionCwd, wanted)
637
- const markdown = buildExportMarkdown(store.getView(), session.id)
638
- try {
639
- await writeFileAsync(target, `${markdown}\n`, 'utf8')
640
- bridge.notify(`exported to ${target}`)
641
- } catch (error: unknown) {
642
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
643
- }
644
- }
645
-
646
- /**
647
- * Rename the session (/title): a user title pins the session and stops
648
- * automatic generation (the service's own contract). The appended
649
- * `session/title` event flows back through the store into the status line.
650
- */
651
- const renameTitle = (argument: string): string => {
652
- const title = argument.trim()
653
- if (title === '') return 'usage: /title <text>'
654
- if (session === undefined) return 'no session yet — submit a message to start'
655
- const service = ctx.get('sessionTitle')
656
- if (service === undefined) return 'session titles are unavailable in this profile'
657
- try {
658
- service.rename(session, title)
659
- return `title → ${title}`
660
- } catch (error: unknown) {
661
- return `rename failed: ${error instanceof Error ? error.message : String(error)}`
662
- }
663
- }
664
-
665
- const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
666
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
667
- const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
668
- // Titles are the expensive fold. Fetch only the first bounded picker page;
669
- // navigation/filter changes trigger a fresh, cancellable observation.
670
- const page = projected.slice(0, 32)
671
- if (page.length === 0) return projected
672
- const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
673
- return mergeSessionTitles(projected, observations)
674
- }
675
-
676
- const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
677
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
678
- const snapshot = await sessionQuery.readSession(id, signal)
679
- return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
680
- }
681
-
682
- const switchModeAction = async (id: string): Promise<string> => {
683
- if (id === '') throw new Error('usage: /mode <preset>')
684
- const currentAgent = agent
685
- const currentActive = active
686
- if (currentAgent === undefined || currentActive === undefined) {
687
- throw new Error('no session yet — submit a message to start')
688
- }
689
- const preset = await switchPreset(presets, currentAgent, id)
690
- currentActive.mode = preset.id
691
- commands.setAgent(currentAgent)
692
- skills.setAgent(currentAgent)
693
- renderCurrent()
694
- return preset.id
695
- }
696
-
697
- interface PendingSwitch { readonly target: Target; readonly label: string }
698
-
699
- const activate = async (nextTarget: Target): Promise<void> => {
700
- const previous = active
701
- const next = await prepare(nextTarget)
702
- active = next
703
- agent = next.agent
704
- session = next.session
705
- store = next.store
706
- mentions = next.mentions
707
- commands.setAgent(agent)
708
- skills.setAgent(agent)
709
- try {
710
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
711
- renderCurrent()
712
- } catch (error: unknown) {
713
- active = previous
714
- agent = previous?.agent
715
- session = previous?.session
716
- store = previous === undefined ? createTranscriptStore() : previous.store
717
- mentions = previous?.mentions
718
- if (agent !== undefined) commands.setAgent(agent)
719
- if (agent !== undefined) skills.setAgent(agent)
720
- await next.handle.dispose()
721
- renderCurrent()
722
- throw error
723
- }
724
- // No previous session (a bare launch switched straight into a resume):
725
- // nothing to flush or dispose, so just confirm the activation.
726
- if (previous === undefined) {
727
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
728
- return
729
- }
730
- let cleanupWarning: string | undefined
731
- try {
732
- await sessions.flush(previous.session)
733
- } catch (error: unknown) {
734
- cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
735
- }
736
- try {
737
- await previous.handle.dispose()
738
- } catch (error: unknown) {
739
- cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
740
- }
741
- bridge.notify(cleanupWarning === undefined
742
- ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
743
- : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
744
- cleanupWarning === undefined ? 'info' : 'warning')
745
- }
746
-
747
- const switchQueue = new SessionSwitchQueue<PendingSwitch>(
748
- async request => { if (!quitting) await activate(request.target) },
749
- error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
750
- )
751
-
752
- const requestSwitch = (request: PendingSwitch): void => {
753
- if (session === undefined) {
754
- // No session yet (a bare launch using /resume before any input): activate
755
- // the target directly — there is no running turn to wait on and nothing
756
- // to flush.
757
- void activate(request.target).catch((error: unknown) => {
758
- bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
759
- })
760
- return
761
- }
762
- if (request.target.sessionId === session.id) {
763
- bridge.notify('that session is already active', 'warning')
764
- return
765
- }
766
- const outcome = switchQueue.request(agent!, request)
767
- if (outcome === 'queued') {
768
- bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
769
- }
770
- }
771
-
772
- const resolveResumeId = async (wanted: string): Promise<string> => {
773
- if (wanted === '') throw new Error('usage: /resume <id|prefix>')
774
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
775
- const records = await sessionQuery.listSessions()
776
- const exact = records.filter(record => record.header.id === wanted)
777
- const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
778
- if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
779
- if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
780
- if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
781
- throw new Error('subagent conversations are read-only in /resume; resume a root session')
782
- }
783
- if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
784
- throw new Error('that session is already live in another owner')
785
- }
786
- return matches[0]!.header.id
787
- }
788
-
789
- const requestResume = (wanted: string): void => {
790
- void resolveResumeId(wanted).then(id => {
791
- requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
792
- }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
793
- }
794
-
795
- const createSession = (mode?: string): void => {
796
- // /new before any input is the first-session creation itself, not a switch.
797
- if (session === undefined) {
798
- ensureSession(mode)
799
- return
800
- }
801
- const nextCwd = session.header.cwd ?? cwd
802
- const id = `session-${randomUUID()}`
803
- requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
804
- }
805
-
806
- const switchSession = (row: SessionRow): void => {
807
- if (!row.resumable) {
808
- bridge.notify('subagent conversations are read-only', 'warning')
809
- return
810
- }
811
- requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
812
- }
813
-
814
- const cancelSessionSwitch = (): boolean => {
815
- return switchQueue.cancel()
816
- }
817
-
818
- const appElement = (): ReturnType<typeof createElement> => {
819
- // A bare launch mounts with placeholder facts until the first input
820
- // composes a real session: empty session id/mode, the deployment default
821
- // model, and the working directory's basename. `status.ts` drops empty
822
- // mode/sessionId, so the bar renders only the identity it actually has.
823
- const sessionCwd = session?.header.cwd ?? cwd
824
- const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
825
- return createElement(App, {
826
- key: session?.id ?? 'pending',
827
- store,
828
- approval,
829
- questions,
830
- commands,
831
- skills,
832
- model,
833
- cwd: basename(sessionCwd),
834
- workspaceRoot: sessionCwd,
835
- branch: gitBranch(sessionCwd),
836
- sessionId: session === undefined ? '' : session.id.slice(-8),
837
- resumed: active?.resumed ?? false,
838
- mode: active?.mode ?? '',
839
- dispatch,
840
- steer,
841
- interrupt,
842
- quit,
843
- loadModels: () => loadModelDirectory(ctx),
844
- loadMentions: mentions === undefined
845
- ? () => Promise.resolve<readonly MentionCandidate[]>([])
846
- : mentions.candidates,
847
- cyclePermission,
848
- selectModel,
849
- exportTranscript,
850
- renameTitle,
851
- loadPresets: () => presets.list(),
852
- switchMode: switchModeAction,
853
- createSession,
854
- loadSessions,
855
- loadSessionTranscript,
856
- switchSession,
857
- cancelSessionSwitch,
858
- loadPlugins: () => listPluginRows(ctx),
859
- statusline: statuslineItems,
860
- saveStatusline,
861
- history: inputHistory,
862
- recordHistory,
863
- cancelQueued,
864
- onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
865
- })
866
- }
867
-
868
- const renderCurrent = (): void => {
869
- mountRef.current?.rerender(appElement())
870
- }
871
-
872
- mountRef.current = io.mount(appElement())
873
-
874
- // A corrupt statusline config must not vanish silently: surface it once
875
- // the notice channel is live, after the first frame settles.
876
- if (statuslineWarning !== undefined) {
877
- setTimeout(() => {
878
- bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
879
- }, 50)
880
- }
881
- }
882
-
883
- /**
884
- * Mount the interactive terminal driver.
885
- * @param ctx - plugin context carrying core services and the launcher-provided exit request.
886
- * @param config - validated startup config resolved from the tuiStartup provider.
887
- */
888
- export function apply(ctx: Context, config: Config): void {
889
- const startup: TuiStartup =
890
- config.startup.kind === 'resume' && config.startup.sessionId !== undefined
891
- ? { kind: 'resume', sessionId: config.startup.sessionId }
892
- : config.startup.kind === 'latest'
893
- ? { kind: 'latest' }
894
- : config.startup.kind === 'named' && config.startup.sessionId !== undefined
895
- ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
896
- : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
897
- // Read through the global service store, not the property proxy: appExit is
898
- // an optional host value, never an injected dependency.
899
- const exit = ctx.get('appExit')
900
- if (exit === undefined) {
901
- throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
902
- }
903
- const io: TuiIo = { mount: internals.mount, exit }
904
- void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
905
- }
1
+ /**
2
+ * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
+ * rides over dsh-base without Host, HTTP, or browser plugins; this runner
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.
8
+ *
9
+ * @module @deepseek-ai/dsh-code
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+ import { readFileSync } from 'node:fs'
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
+ import { createElement } from 'react'
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
+ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
+ import type {} from '@deepseek-ai/dsh-agent-default-model'
23
+ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
24
+ import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
25
+ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
26
+ // Type-only: carries the ctx.sessionTitle service merge for /title.
27
+ import type {} from '@deepseek-ai/dsh-session-title'
28
+ // Empty type imports carry the loader Context merge for the settlement await
29
+ // and the cmdline Context merge for the appExit host value.
30
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
31
+ import type {} from '@deepseek-ai/dsh-cmdline'
32
+ import { App, type NoticeTone } from './app.ts'
33
+ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
34
+ import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
35
+ import { internals, type TuiMount } from './internals.ts'
36
+ import { buildModelSelection, loadModelDirectory, resolveEffectiveSelection, type ModelRow } from './models.ts'
37
+ import {
38
+ loadProviderSettings,
39
+ removeProviderSettings,
40
+ saveProviderCredential,
41
+ subscribeProviderSettings,
42
+ unsetProviderCredential,
43
+ } from './provider-settings.ts'
44
+ import { createMentions, type MentionsApi } from './mentions.ts'
45
+ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
46
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
47
+ import { parseStatuslineItems } from './render/status.ts'
48
+ import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
49
+ import { watchSkills, type SkillsView } from './skills.ts'
50
+ import { toolArgumentsPreview } from './render/tool-preview.ts'
51
+ import { buildExportMarkdown } from './render/export.ts'
52
+ import type { TuiStartup } from './startup.ts'
53
+ import { SessionSwitchQueue } from './session-switch.ts'
54
+ import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
55
+ import {
56
+ applyPendingPermission,
57
+ cyclePermission as cyclePermissionPreset,
58
+ effectivePermission,
59
+ listPermissionRows,
60
+ permissionPresetsFrom,
61
+ selectPermission,
62
+ } from './permissions.ts'
63
+ import { listPluginRows } from './plugin-inventory.ts'
64
+ import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
65
+ import {
66
+ isSubagentSession,
67
+ matchSessionId,
68
+ mergeSessionTitles,
69
+ newestRootForCwd,
70
+ projectSessionRows,
71
+ type SessionDirectoryOptions,
72
+ type SessionQueryService,
73
+ type SessionRow,
74
+ } from './session-directory.ts'
75
+
76
+ /** Stable Cordis plugin name. */
77
+ export const name = 'tui-runner'
78
+
79
+ /** Core services required before the interactive session can start. */
80
+ export const inject = ['agentDefaultModel', 'agents', 'sessions']
81
+
82
+ /** Plugin config: the startup resolved from this app's injected provider service. */
83
+ export interface Config {
84
+ /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
85
+ startup: { kind: string; sessionId?: string; mode?: string; theme?: string }
86
+ }
87
+
88
+ export const Config: z<Config> = z.object({
89
+ startup: z.object({
90
+ kind: z.string().required(),
91
+ sessionId: z.string(),
92
+ mode: z.string(),
93
+ theme: z.string(),
94
+ }),
95
+ })
96
+
97
+ /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
98
+ interface TuiIo {
99
+ mount: typeof internals.mount
100
+ exit(code: number): void
101
+ }
102
+
103
+ /** Report an unexpected direct-driver failure and request a failing exit. */
104
+ function fail(io: TuiIo, error: unknown): void {
105
+ internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
106
+ io.exit(1)
107
+ }
108
+
109
+ /**
110
+ * Resolve the working directory's git branch for the status line.
111
+ * @param cwd - the session's working directory.
112
+ * @returns the branch name, or '' outside a repository or on a detached HEAD.
113
+ */
114
+ function gitBranch(cwd: string): string {
115
+ try {
116
+ const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
117
+ return ref?.[1] ?? ''
118
+ } catch {
119
+ // Only the single HEAD read is attempted, so the sole reachable failure is
120
+ // a missing repository (or unreadable HEAD file): the branch group drops out.
121
+ return ''
122
+ }
123
+ }
124
+
125
+ /** The session identity this invocation will run, plus whether it is resumed. */
126
+ interface Target {
127
+ sessionId: string
128
+ resume: boolean
129
+ mode?: string
130
+ cwd?: string
131
+ }
132
+
133
+ /**
134
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
135
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
136
+ * user text: path separators must never leak into the default export filename
137
+ * (which would escape the session cwd).
138
+ * @param id - the session id.
139
+ * @returns at most the last 8 filename-safe characters.
140
+ */
141
+ export function exportSessionIdSuffix(id: string): string {
142
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
143
+ }
144
+
145
+ /** One ordered step of the terminal quit cleanup. */
146
+ export interface QuitCleanupStep {
147
+ /** Step label used in diagnostics and tests. */
148
+ readonly name: string
149
+ /** The step's async work; a rejection is contained by the sequence. */
150
+ readonly run: () => Promise<void>
151
+ }
152
+
153
+ /**
154
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
155
+ * contained (reported through `onError`) so a failed flush or dispose never
156
+ * skips the remaining cleanup; the exit request is always reached exactly
157
+ * once.
158
+ * @param steps - the cleanup steps in dependency order (settle the visible
159
+ * session, await the final in-flight composition, await durable recall).
160
+ * @param exit - the terminal exit request (code 0).
161
+ * @param onError - optional failure sink; called once per failing step and
162
+ * itself contained, so a throwing sink cannot abort the sequence.
163
+ * @returns the names of the steps that started, in order (for tests).
164
+ */
165
+ export async function runQuitSequence(
166
+ steps: readonly QuitCleanupStep[],
167
+ exit: (code: number) => void,
168
+ onError?: (name: string, error: unknown) => void,
169
+ ): Promise<readonly string[]> {
170
+ const started: string[] = []
171
+ for (const step of steps) {
172
+ started.push(step.name)
173
+ try {
174
+ await step.run()
175
+ } catch (error) {
176
+ try {
177
+ onError?.(step.name, error)
178
+ } catch {
179
+ // The failure sink must never abort the cleanup sequence.
180
+ }
181
+ }
182
+ }
183
+ try {
184
+ exit(0)
185
+ } catch {
186
+ // The exit request itself must not become an unhandled rejection.
187
+ }
188
+ return started
189
+ }
190
+
191
+ /**
192
+ * Resolve the invocation's target session against the persisted headers.
193
+ * @param startup - the parsed startup flags.
194
+ * @param persistence - the persistence service; required for resume/latest.
195
+ * @param cwd - the working directory `--continue` filters by.
196
+ * @returns the target identity.
197
+ * @throws with a user-facing message when the flags name nothing resolvable.
198
+ */
199
+ export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
200
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
201
+ if (startup.kind === 'named') {
202
+ // The id must not exist yet: reject before any Agent composition when the
203
+ // backend can tell us (a live collision is still caught by the session
204
+ // store at create time).
205
+ if (persistence !== undefined) {
206
+ const headers: readonly SessionHeader[] = await persistence.list()
207
+ if (headers.some(header => header.id === startup.sessionId)) {
208
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
209
+ }
210
+ }
211
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
212
+ }
213
+ if (persistence === undefined) {
214
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
215
+ }
216
+ const headers: readonly SessionHeader[] = await persistence.list()
217
+ if (startup.kind === 'resume') {
218
+ const matched = matchSessionId(headers, startup.sessionId)
219
+ // Subagent conversations are read-only everywhere else; the CLI must not
220
+ // be a back door into appending root turns to a child's durable log.
221
+ if (isSubagentSession(matched)) {
222
+ throw new Error('subagent conversations are read-only; resume a root session')
223
+ }
224
+ return { sessionId: matched.id, resume: true }
225
+ }
226
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
227
+ const newest = newestRootForCwd(headers, cwd)
228
+ if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
229
+ return { sessionId: newest.id, resume: true }
230
+ }
231
+
232
+ /**
233
+ * Resolve a bounded command preview for one pending approval: the request
234
+ * contract carries no arguments, so the bar self-serves from the transcript
235
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
236
+ * @param events - the transcript entries to search.
237
+ * @param callId - the tool call the question is about, when the asker had one.
238
+ * @param toolName - the tool the question is about.
239
+ * @returns a bounded preview line, '' when nothing useful resolves.
240
+ */
241
+ function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
242
+ if (callId === undefined) return ''
243
+ const entry = events.find(candidate =>
244
+ candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
245
+ if (entry === undefined) return ''
246
+ const args = (entry as { arguments?: string }).arguments ?? ''
247
+ return toolArgumentsPreview(args, toolName)
248
+ }
249
+
250
+ /** The runner's connection between the React app and the process side. */
251
+ interface AppBridge {
252
+ /** Post one local notice line (feedback the transcript does not carry). */
253
+ notify(text: string, tone?: NoticeTone): void
254
+ }
255
+
256
+ /**
257
+ * Run the interactive terminal session: resolve the target session, create or
258
+ * resume one Agent, mount the app, and keep the process alive until the user
259
+ * quits.
260
+ * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
261
+ * @param startup - the parsed invocation flags.
262
+ * @param io - process-facing effects.
263
+ */
264
+ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
265
+ // Loader siblings mount concurrently. Await the complete application before
266
+ // creating an Agent so its scoped tools and adapters are not half-composed.
267
+ await ctx.get('loader')?.await()
268
+ const agents = ctx.get('agents')
269
+ const defaultModel = ctx.get('agentDefaultModel')
270
+ const sessions = ctx.get('sessions')
271
+ const persistence = ctx.get('sessionPersistence')
272
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
273
+ // Early process shutdown can dispose the tree while settlement is pending.
274
+ if (agents === undefined || defaultModel === undefined || sessions === undefined) return
275
+
276
+ const cwd = process.cwd()
277
+ const defaults = defaultModel.currentSelection()
278
+ const presets = agentPresetsFrom(ctx)
279
+ if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
280
+ const permissionPresets = permissionPresetsFrom(ctx)
281
+
282
+ // A bare fresh launch stays transient: no Agent or session is composed, and
283
+ // nothing is persisted, until the user's first real input. Explicit flags
284
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
285
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
286
+
287
+ interface ActiveSession {
288
+ handle: AgentHandle
289
+ agent: Agent
290
+ session: Session
291
+ store: ReturnType<typeof createTranscriptStore>
292
+ mentions: MentionsApi
293
+ mode: string
294
+ selection: { picked?: ModelSelection }
295
+ resumed: boolean
296
+ }
297
+
298
+ /** Prepare a complete next session before disturbing the currently visible one. */
299
+ const prepare = async (next: Target): Promise<ActiveSession> => {
300
+ const nextCwd = next.cwd ?? cwd
301
+ // A bare launch can pick a model before any session exists: the process
302
+ // keeps that explicit choice and every prepared session starts from it
303
+ // (the documented precedence: explicit pick > session header > default).
304
+ const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
305
+ ? {}
306
+ : { picked: pendingSelection }
307
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode
308
+ if (!next.resume) mode = (await presets.resolve(mode)).id
309
+ const setup = async (agentCtx: Context): Promise<void> => {
310
+ const sessionPreset = next.resume
311
+ ? resolvePreset(agentCtx.agent!.session)
312
+ : mode
313
+ const mounted = await presets.mount(agentCtx, sessionPreset)
314
+ mode = mounted.id
315
+ const selection: ModelSelectionRef = {
316
+ get current(): ModelSelection | undefined {
317
+ return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, defaults)
318
+ },
319
+ set current(value: ModelSelection | undefined) { selectionState.picked = value },
320
+ assembled: undefined,
321
+ }
322
+ installModelSelection(agentCtx, selection)
323
+ }
324
+ const handle = next.resume
325
+ ? await agents.resume({
326
+ resumeSessionId: SessionId(next.sessionId),
327
+ agentOptions: { provider: defaults.provider, model: defaults.model },
328
+ // Quit aborts an in-flight composition so the exit wait never hangs
329
+ // on a prepare that cannot settle; upstream rolls the creation back.
330
+ signal: quitAbort.signal,
331
+ setup,
332
+ })
333
+ : await agents.create({
334
+ sessionId: SessionId(next.sessionId),
335
+ meta: { cwd: nextCwd, agentPreset: mode },
336
+ agentOptions: { provider: defaults.provider, model: defaults.model },
337
+ signal: quitAbort.signal,
338
+ setup,
339
+ })
340
+ const session = handle.agent.session
341
+ if (!next.resume && permissionPresets !== undefined) {
342
+ applyPendingPermission(permissionPresets, session, pendingPermission)
343
+ }
344
+ const sessionCwd = session.header.cwd ?? nextCwd
345
+ return {
346
+ handle,
347
+ agent: handle.agent,
348
+ session,
349
+ store: createTranscriptStore(session.events),
350
+ mentions: createMentions(ctx, handle.agent, sessionCwd),
351
+ mode: mode ?? 'standard',
352
+ selection: selectionState,
353
+ resumed: next.resume,
354
+ }
355
+ }
356
+
357
+ let active: ActiveSession | undefined
358
+ let agent: Agent | undefined
359
+ let session: Session | undefined
360
+ let store: TranscriptStore = createTranscriptStore()
361
+ // File-only mentions from the start: `@` completion works on a bare launch
362
+ // (no session yet); the prepare/activate paths replace this with the full
363
+ // agent-scoped instance that also resolves session references.
364
+ let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
365
+ /** Explicit model pick made before any session exists (a bare launch). */
366
+ let pendingSelection: ModelSelection | undefined
367
+ /** Agent preset selected before the first session exists. */
368
+ let pendingMode: string | undefined
369
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
370
+ let pendingModeWork: Promise<void> = Promise.resolve()
371
+ /** Permission preset selected before the first session exists. */
372
+ let pendingPermission: string | undefined
373
+ /**
374
+ * Monotonic session epoch: bumped on every successful activation, on every
375
+ * first-session creation, and on quit. Async callbacks (mention prepares,
376
+ * command executions) capture it at call time and drop their result when it
377
+ * changed, so a stale callback can never deliver to an agent that is no
378
+ * longer on screen.
379
+ */
380
+ let epoch = 0
381
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
382
+ const quitAbort = new AbortController()
383
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
384
+ const pendingControllers = new Set<AbortController>()
385
+ const abortPendingControllers = (): void => {
386
+ for (const controller of [...pendingControllers]) {
387
+ pendingControllers.delete(controller)
388
+ controller.abort()
389
+ }
390
+ }
391
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
392
+ let composing: Promise<void> | undefined
393
+ /**
394
+ * Run one session composition exclusively: concurrent compositions wait
395
+ * their turn, so a bare-launch first-session creation and a /resume
396
+ * activation can never compose agents in parallel (the loser would leak its
397
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
398
+ * always continues.
399
+ */
400
+ const compose = (work: () => Promise<void>): Promise<void> => {
401
+ const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
402
+ composing = turn.catch(() => {})
403
+ return turn
404
+ }
405
+
406
+ if (!lazy) {
407
+ const target = await resolveTarget(startup, persistence, cwd)
408
+ const prepared = await prepare(target)
409
+ active = prepared
410
+ agent = prepared.agent
411
+ session = prepared.session
412
+ store = prepared.store
413
+ mentions = prepared.mentions
414
+ }
415
+
416
+ // Seed the transcript from the full session log: constructor seeds never
417
+ // fire on `session/event`, so a resumed session paints its history once
418
+ // before the first render. The handler reads the current session/store, so
419
+ // the deferred first session of a bare launch is covered by the same feed.
420
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
421
+ if (session !== undefined && subject.id === session.id) store.apply(event)
422
+ })
423
+
424
+ const commands: CommandsView = watchCommands(ctx)
425
+ if (agent !== undefined) commands.setAgent(agent)
426
+
427
+ const skills: SkillsView = watchSkills(ctx)
428
+ if (agent !== undefined) skills.setAgent(agent)
429
+
430
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
431
+ // claimed, every other ask falls through to the fail-closed waterfall. The
432
+ // owner predicate is empty until the first session exists.
433
+ const approval: ApprovalStore = mountApprovalAnswerer(
434
+ ctx,
435
+ candidate => agent !== undefined && candidate.id === agent.id,
436
+ request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
437
+ )
438
+
439
+ // ask_user_question provider: the single UI provider on the shared service,
440
+ // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
441
+ // through this same pipe.
442
+ const questions: QuestionStore = mountQuestionProvider(ctx)
443
+
444
+ // The bridge the React app registers on mount: local notices from the
445
+ // process side (unknown commands, switch confirmations, cancels).
446
+ const bridge: AppBridge = { notify: () => {} }
447
+
448
+ // /statusline persistence: one user-level JSON file under the DSH home.
449
+ // Missing file means defaults; a corrupt file degrades to defaults with a
450
+ // surfaced warning (the customization is user-authored, never silent).
451
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
452
+ let statuslineWarning: string | undefined
453
+ let statuslineItems: readonly string[] = []
454
+ try {
455
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
456
+ } catch (error) {
457
+ statuslineItems = parseStatuslineItems(undefined)
458
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
459
+ statuslineWarning = error instanceof Error ? error.message : String(error)
460
+ }
461
+ }
462
+ const saveStatusline = (items: readonly string[]): void => {
463
+ statuslineItems = [...items]
464
+ // The config directory may not exist on a first save; create it before
465
+ // the write so a fresh install persists customizations.
466
+ void mkdir(dirname(statuslinePath), { recursive: true })
467
+ .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
468
+ .catch((writeError: unknown) => {
469
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
470
+ })
471
+ }
472
+
473
+ // /theme persistence: one user-level JSON file under the DSH home, mirroring
474
+ // the statusline file. A missing file means the dark default; a corrupt file
475
+ // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
476
+ // auto detection > dark (auto detection itself is a later enhancement and
477
+ // currently falls back to dark inside theme.ts).
478
+ const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
479
+ let themeWarning: string | undefined
480
+ if (startup.theme === undefined) {
481
+ try {
482
+ setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
483
+ } catch (error) {
484
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
485
+ themeWarning = error instanceof Error ? error.message : String(error)
486
+ }
487
+ }
488
+ } else {
489
+ setTheme(startup.theme)
490
+ }
491
+ const saveTheme = (name: ThemeName): void => {
492
+ setTheme(name)
493
+ void mkdir(dirname(themePath), { recursive: true })
494
+ .then(() => writeFileAsync(themePath, JSON.stringify({ theme: name }, null, 2) + '\n', 'utf8'))
495
+ .catch((writeError: unknown) => {
496
+ bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
497
+ })
498
+ }
499
+
500
+ // Global input recall (Codex composer-history contract): one JSONL file
501
+ // under the DSH home. A missing file means an empty history; unreadable or
502
+ // corrupt content degrades to the valid lines it could parse, silently —
503
+ // recall is a convenience surface, never a gate.
504
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
505
+ let inputHistory: readonly string[] = []
506
+ try {
507
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
508
+ } catch {
509
+ inputHistory = []
510
+ }
511
+ /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
512
+ let historyWriteChain: Promise<void> = Promise.resolve()
513
+ const recordHistory = (text: string): void => {
514
+ if (text === '') return
515
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
516
+ // Write the whole current list, serialized per submission: the file is
517
+ // never read back on the submit path, so rapid same-process submissions
518
+ // cannot lose entries to a read-modify-write race.
519
+ historyWriteChain = historyWriteChain
520
+ .then(() => mkdir(dirname(historyPath), { recursive: true }))
521
+ .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
522
+ .catch((writeError: unknown) => {
523
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
524
+ })
525
+ }
526
+
527
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
528
+ const cancelQueued = (messageId: string): void => {
529
+ if (agent === undefined) return
530
+ try {
531
+ if (agent.inbox.remove(MessageId(messageId))) {
532
+ bridge.notify('queued message cancelled')
533
+ }
534
+ } catch (error: unknown) {
535
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
536
+ }
537
+ }
538
+
539
+ // The mount handle lives in a box: quit closes over it, while the mount
540
+ // itself is created after quit (the App element needs quit as a prop).
541
+ const mountRef: { current?: TuiMount } = {}
542
+ let quitting = false
543
+ const quit = (): void => {
544
+ if (quitting) return
545
+ quitting = true
546
+ switchQueue.cancel()
547
+ // Stale prepares/commands die with the session they were for. Aborting
548
+ // the composition signal lets a never-settling prepare reject, so the
549
+ // exit wait below cannot hang (upstream rolls the creation back).
550
+ abortPendingControllers()
551
+ quitAbort.abort()
552
+ epoch += 1
553
+ off()
554
+ mountRef.current?.unmount()
555
+ const currentSession = session
556
+ const currentActive = active
557
+ const report = (name: string, error: unknown): void => {
558
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
559
+ }
560
+ // One ordered cleanup: settle the visible session (if any — a bare launch
561
+ // that never composed one resolves immediately), then wait for the final
562
+ // in-flight composition (its work swallows errors and the quitting guard
563
+ // disposes any half-prepared agent), then flush the durable recall, then
564
+ // request exit. `composing` and `historyWriteChain` are read at step run
565
+ // time, so a turn that was still being queued when quit ran is included.
566
+ // A failing step must never skip the remaining cleanup.
567
+ const steps: QuitCleanupStep[] = [
568
+ ...(currentSession === undefined || currentActive === undefined
569
+ ? []
570
+ : [
571
+ { name: 'flush', run: async () => { await sessions.flush(currentSession) } },
572
+ { name: 'dispose', run: () => currentActive.handle.dispose() },
573
+ ]),
574
+ { name: 'composing', run: () => composing ?? Promise.resolve() },
575
+ { name: 'history', run: () => historyWriteChain },
576
+ ]
577
+ void runQuitSequence(steps, io.exit, report)
578
+ }
579
+
580
+ /** Run one slash line through the command registry (closed namespace). */
581
+ const runSlash = (line: string): void => {
582
+ const currentAgent = agent
583
+ if (currentAgent === undefined) return
584
+ if (line.startsWith('/resume ')) {
585
+ requestResume(line.slice(8).trim())
586
+ return
587
+ }
588
+ const registry = ctx.get('commands')
589
+ if (registry === undefined) {
590
+ bridge.notify('no command registry is mounted in this composition', 'error')
591
+ return
592
+ }
593
+ const controller = new AbortController()
594
+ const atEpoch = epoch
595
+ pendingControllers.add(controller)
596
+ const finish = (): void => {
597
+ pendingControllers.delete(controller)
598
+ }
599
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
600
+ finish()
601
+ // A switch/quit landed while the command ran: its fall-through must not
602
+ // reach an agent that is no longer on screen.
603
+ if (epoch !== atEpoch || agent !== currentAgent) return
604
+ if (execution === undefined) {
605
+ // No command owns this line: send it verbatim so a user-invocable
606
+ // skill gesture (`/skill-name`) reaches the host's tool-skill
607
+ // pre-step injection the web composer's same fall-through.
608
+ try {
609
+ currentAgent.followup(createUserMessage({
610
+ content: [{ type: 'text', text: line }],
611
+ source: { kind: 'user' },
612
+ }))
613
+ } catch (error: unknown) {
614
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
615
+ }
616
+ }
617
+ }, (error: unknown) => {
618
+ finish()
619
+ if (epoch !== atEpoch || agent !== currentAgent) return
620
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
621
+ })
622
+ }
623
+
624
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
625
+ const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
626
+ const currentAgent = agent!
627
+ const currentMentions = mentions!
628
+ // The command registry is a closed namespace: slash lines run out of
629
+ // band and never reach the model through this path (steering keeps the
630
+ // registry out of the inbox, so slash lines steer as literal text).
631
+ if (isSlashLine(line) && mode === 'followup') {
632
+ runSlash(line)
633
+ return
634
+ }
635
+ let parsed: ReturnType<MentionsApi['parse']>
636
+ try {
637
+ parsed = currentMentions.parse(line)
638
+ } catch (error: unknown) {
639
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
640
+ return
641
+ }
642
+ const atEpoch = epoch
643
+ const deliver = (readable: string, context?: UserMessage): void => {
644
+ // A switch/quit landed while the snapshot was being prepared: never
645
+ // deliver to an agent that is no longer on screen.
646
+ if (epoch !== atEpoch || agent !== currentAgent) return
647
+ // Session snapshots ride the inbox as model-facing context ahead of
648
+ // the readable message (upstream README wiring: inject before the
649
+ // followup/steer that wakes the driver).
650
+ try {
651
+ if (context !== undefined) currentAgent.inject(context)
652
+ const message = createUserMessage({
653
+ content: [{ type: 'text', text: readable }],
654
+ source: { kind: 'user' },
655
+ })
656
+ if (mode === 'steer') {
657
+ // The queued message is visible as a pending transcript row (the
658
+ // web queue-mirror contract); no notice noise on the happy path.
659
+ currentAgent.steer(message)
660
+ } else {
661
+ currentAgent.followup(message)
662
+ }
663
+ } catch (error: unknown) {
664
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
665
+ }
666
+ }
667
+ if (parsed.references.length === 0) {
668
+ deliver(parsed.text)
669
+ return
670
+ }
671
+ const controller = new AbortController()
672
+ pendingControllers.add(controller)
673
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
674
+ pendingControllers.delete(controller)
675
+ deliver(prepared.text, prepared.additionalContext)
676
+ }, (error: unknown) => {
677
+ pendingControllers.delete(controller)
678
+ if (controller.signal.aborted || epoch !== atEpoch) return
679
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
680
+ })
681
+ }
682
+
683
+ // Deferred first-session creation for a bare launch: the session is composed
684
+ // only when the user submits real input (or /new), and every line that
685
+ // arrives during creation is delivered in order afterwards. A creation
686
+ // failure reports and clears the queue, leaving the transient state ready
687
+ // for the next attempt.
688
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
689
+ // A creation is queued/running: further submissions must not mint more
690
+ // fresh sessions (their lines queue into pendingInputs instead).
691
+ let creating = false
692
+ const ensureSession = (mode?: string): void => {
693
+ if (creating) return
694
+ creating = true
695
+ void compose(async () => {
696
+ try {
697
+ // A direct `/mode <preset>` resolves asynchronously. Preserve submit
698
+ // order so the first composition cannot race ahead with the old mode.
699
+ await pendingModeWork
700
+ // Another composition (e.g. a /resume activated while this creation
701
+ // waited its turn) may have published a session already: deliver the
702
+ // queued lines there instead of minting a competing fresh session
703
+ // (which would orphan the live one without a dispose).
704
+ if (session !== undefined) {
705
+ const queued = pendingInputs.splice(0)
706
+ for (const item of queued) deliverLine(item.text, item.mode)
707
+ return
708
+ }
709
+ const next = await prepare({
710
+ sessionId: `session-${randomUUID()}`,
711
+ resume: false,
712
+ ...(mode === undefined ? {} : { mode }),
713
+ })
714
+ if (quitting) {
715
+ void next.handle.dispose().catch(() => {})
716
+ return
717
+ }
718
+ active = next
719
+ agent = next.agent
720
+ session = next.session
721
+ store = next.store
722
+ mentions = next.mentions
723
+ pendingMode = undefined
724
+ pendingPermission = undefined
725
+ commands.setAgent(agent)
726
+ skills.setAgent(agent)
727
+ // The App mounts with a placeholder key until the first input; the
728
+ // key-change remount below must start from a clean screen or the ghost
729
+ // static header stays visible above the new one (same source-backed
730
+ // clear the session-switch path performs).
731
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
732
+ renderCurrent()
733
+ abortPendingControllers()
734
+ epoch += 1
735
+ const queued = pendingInputs.splice(0)
736
+ for (const item of queued) deliverLine(item.text, item.mode)
737
+ } finally {
738
+ creating = false
739
+ }
740
+ }).catch((error: unknown) => {
741
+ pendingInputs.length = 0
742
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
743
+ })
744
+ }
745
+
746
+ /** Deliver one readable line to the agent, expanding session mentions first. */
747
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
748
+ const line = text.trim()
749
+ if (line === '') return
750
+ if (line.startsWith('/mode ')) {
751
+ void switchModeAction(line.slice(6).trim()).then(
752
+ selected => bridge.notify(`mode ${selected}`),
753
+ error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
754
+ )
755
+ return
756
+ }
757
+ if (line.startsWith('/permission ')) {
758
+ try {
759
+ const selected = setPermissionAction(line.slice(12).trim())
760
+ bridge.notify(`permission → ${selected}`)
761
+ } catch (error: unknown) {
762
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
763
+ }
764
+ return
765
+ }
766
+ if (session === undefined) {
767
+ pendingInputs.push({ text: line, mode })
768
+ ensureSession()
769
+ return
770
+ }
771
+ deliverLine(line, mode)
772
+ }
773
+
774
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
775
+ const dispatch = (text: string): void => {
776
+ send(text, 'followup')
777
+ }
778
+
779
+ /**
780
+ * Submit steering: a running driver consumes the text at its next step
781
+ * boundary (the inbox delivers between steps); an idle driver just starts
782
+ * a turn, so this doubles as the busy-state submit path.
783
+ */
784
+ const steer = (text: string): void => {
785
+ send(text, 'steer')
786
+ }
787
+
788
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
789
+ const interrupt = (): boolean => {
790
+ if (agent === undefined || agent.status !== 'running') return false
791
+ try {
792
+ agent.cancel({ kind: 'user' })
793
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
794
+ return true
795
+ } catch (error: unknown) {
796
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
797
+ return false
798
+ }
799
+ }
800
+
801
+ /** Select one permission preset before the first session or on the active one. */
802
+ const setPermissionAction = (id: string): string => {
803
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
804
+ throw new Error('permission presets are not mounted in this composition')
805
+ }
806
+ if (id === '') throw new Error('usage: /permission <preset>')
807
+ const selected = selectPermission(permissionPresets, session, id)
808
+ if (session === undefined) {
809
+ pendingPermission = selected
810
+ renderCurrent()
811
+ }
812
+ return selected
813
+ }
814
+
815
+ /**
816
+ * Cycle to the next permission preset (Shift+Tab). Before the first session,
817
+ * the choice remains process-local and is materialized when Harness creates
818
+ * that session; afterwards the canonical service writes durable events.
819
+ */
820
+ const cyclePermission = (): string => {
821
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
822
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
823
+ return ''
824
+ }
825
+ try {
826
+ const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
827
+ if (session === undefined && next !== '') {
828
+ pendingPermission = next
829
+ renderCurrent()
830
+ }
831
+ return next
832
+ } catch (error: unknown) {
833
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
834
+ return ''
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Apply one /model selection: takes effect from the next assembled step.
840
+ * The optional reasoning effort must be one the row advertises (the picker
841
+ * only offers those), so an unsupported value cannot reach the request
842
+ * pipeline; an absent effort restores the model's own default.
843
+ */
844
+ const selectModel = (row: ModelRow, effortId?: string): string => {
845
+ const selection = buildModelSelection(row, effortId)
846
+ if (active === undefined) {
847
+ // A bare launch has no session yet: keep the pick process-wide so the
848
+ // first composed session starts from it.
849
+ pendingSelection = selection
850
+ } else {
851
+ active.selection.picked = selection
852
+ }
853
+ return `${row.provider}/${row.model}`
854
+ }
855
+
856
+ /**
857
+ * Export the folded transcript to a markdown file (/export). The default
858
+ * target sits beside the session's cwd so the file lands in the user's
859
+ * workspace; an absolute or cwd-relative argument overrides it.
860
+ */
861
+ const exportTranscript = async (argument: string): Promise<void> => {
862
+ if (session === undefined) {
863
+ bridge.notify('no session yet — submit a message to start', 'warning')
864
+ return
865
+ }
866
+ const wanted = argument.trim()
867
+ const sessionCwd = session.header.cwd ?? cwd
868
+ // The default name derives from the session id, which `--session` lets the
869
+ // user spell freely: reduce it to filename-safe characters first so the
870
+ // default target can never escape the session cwd.
871
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
872
+ const target = wanted === ''
873
+ ? join(sessionCwd, defaultName)
874
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
875
+ ? wanted
876
+ : join(sessionCwd, wanted)
877
+ const markdown = buildExportMarkdown(store.getView(), session.id)
878
+ try {
879
+ await writeFileAsync(target, `${markdown}\n`, 'utf8')
880
+ bridge.notify(`exported to ${target}`)
881
+ } catch (error: unknown) {
882
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Rename the session (/title): a user title pins the session and stops
888
+ * automatic generation (the service's own contract). The appended
889
+ * `session/title` event flows back through the store into the status line.
890
+ */
891
+ const renameTitle = (argument: string): string => {
892
+ const title = argument.trim()
893
+ if (title === '') return 'usage: /title <text>'
894
+ if (session === undefined) return 'no session yet submit a message to start'
895
+ const service = ctx.get('sessionTitle')
896
+ if (service === undefined) return 'session titles are unavailable in this profile'
897
+ try {
898
+ service.rename(session, title)
899
+ return `title ${title}`
900
+ } catch (error: unknown) {
901
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`
902
+ }
903
+ }
904
+
905
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
906
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
907
+ const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
908
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
909
+ // navigation/filter changes trigger a fresh, cancellable observation.
910
+ const page = projected.slice(0, 32)
911
+ if (page.length === 0) return projected
912
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
913
+ return mergeSessionTitles(projected, observations)
914
+ }
915
+
916
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
917
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
918
+ const snapshot = await sessionQuery.readSession(id, signal)
919
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
920
+ }
921
+
922
+ const switchModeAction = async (id: string): Promise<string> => {
923
+ if (id === '') throw new Error('usage: /mode <preset>')
924
+ const currentAgent = agent
925
+ if (currentAgent === undefined) {
926
+ const choice = pendingModeWork.then(async () => {
927
+ const preset = await selectPreset(presets, undefined, id)
928
+ // A resume may have won while this roster read was in flight; never
929
+ // leak the old pending choice into a later /new session.
930
+ if (agent === undefined) {
931
+ pendingMode = preset.id
932
+ renderCurrent()
933
+ }
934
+ return preset.id
935
+ })
936
+ pendingModeWork = choice.then(() => {}, () => {})
937
+ return choice
938
+ }
939
+
940
+ const preset = await selectPreset(presets, currentAgent, id)
941
+ if (active === undefined) throw new Error('active Agent has no session state')
942
+ active.mode = preset.id
943
+ commands.setAgent(currentAgent)
944
+ skills.setAgent(currentAgent)
945
+ renderCurrent()
946
+ return preset.id
947
+ }
948
+
949
+ interface PendingSwitch { readonly target: Target; readonly label: string }
950
+
951
+ const activate = (nextTarget: Target): Promise<void> => {
952
+ if (quitting) return Promise.resolve()
953
+ // Serialized with every other composition (bare-launch creation, queued
954
+ // switches): at most one agent is composed at a time.
955
+ return compose(async () => {
956
+ const previous = active
957
+ const next = await prepare(nextTarget)
958
+ // Quit landed while the next session was being composed: dispose the
959
+ // half-ready agent and leave the current session untouched.
960
+ if (quitting) {
961
+ await next.handle.dispose().catch(() => {})
962
+ return
963
+ }
964
+ active = next
965
+ agent = next.agent
966
+ session = next.session
967
+ store = next.store
968
+ mentions = next.mentions
969
+ pendingMode = undefined
970
+ pendingPermission = undefined
971
+ commands.setAgent(agent)
972
+ skills.setAgent(agent)
973
+ try {
974
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
975
+ renderCurrent()
976
+ } catch (error: unknown) {
977
+ active = previous
978
+ agent = previous?.agent
979
+ session = previous?.session
980
+ store = previous === undefined ? createTranscriptStore() : previous.store
981
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
982
+ if (agent !== undefined) commands.setAgent(agent)
983
+ if (agent !== undefined) skills.setAgent(agent)
984
+ await next.handle.dispose()
985
+ if (!quitting) renderCurrent()
986
+ throw error
987
+ }
988
+ // From here the new session is live: in-flight prepares/commands for
989
+ // the previous agent are stale and must be aborted and ignored.
990
+ abortPendingControllers()
991
+ epoch += 1
992
+ // No previous session (a bare launch switched straight into a resume):
993
+ // nothing to flush or dispose, so just confirm the activation.
994
+ if (previous === undefined) {
995
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
996
+ return
997
+ }
998
+ let cleanupWarning: string | undefined
999
+ try {
1000
+ await sessions.flush(previous.session)
1001
+ } catch (error: unknown) {
1002
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
1003
+ }
1004
+ try {
1005
+ await previous.handle.dispose()
1006
+ } catch (error: unknown) {
1007
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
1008
+ }
1009
+ bridge.notify(cleanupWarning === undefined
1010
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1011
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
1012
+ cleanupWarning === undefined ? 'info' : 'warning')
1013
+ })
1014
+ }
1015
+
1016
+ const switchQueue = new SessionSwitchQueue<PendingSwitch>(
1017
+ async request => { if (!quitting) await activate(request.target) },
1018
+ error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1019
+ )
1020
+
1021
+ const requestSwitch = (request: PendingSwitch): void => {
1022
+ if (session === undefined) {
1023
+ // No session yet (a bare launch using /resume before any input): activate
1024
+ // the target directly — there is no running turn to wait on and nothing
1025
+ // to flush.
1026
+ void activate(request.target).catch((error: unknown) => {
1027
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1028
+ })
1029
+ return
1030
+ }
1031
+ if (request.target.sessionId === session.id) {
1032
+ bridge.notify('that session is already active', 'warning')
1033
+ return
1034
+ }
1035
+ const outcome = switchQueue.request(agent!, request)
1036
+ if (outcome === 'queued') {
1037
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
1038
+ }
1039
+ }
1040
+
1041
+ const resolveResumeId = async (wanted: string): Promise<string> => {
1042
+ if (wanted === '') throw new Error('usage: /resume <id|prefix>')
1043
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1044
+ const records = await sessionQuery.listSessions()
1045
+ const exact = records.filter(record => record.header.id === wanted)
1046
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
1047
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
1048
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
1049
+ const matched = matches[0]!
1050
+ // Same lineage gate as the CLI --resume path and the picker.
1051
+ if (isSubagentSession(matched.header)) {
1052
+ throw new Error('subagent conversations are read-only; resume a root session')
1053
+ }
1054
+ if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
1055
+ throw new Error('that session is already live in another owner')
1056
+ }
1057
+ return matched.header.id
1058
+ }
1059
+
1060
+ const requestResume = (wanted: string): void => {
1061
+ void resolveResumeId(wanted).then(id => {
1062
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
1063
+ }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
1064
+ }
1065
+
1066
+ const createSession = (mode?: string): void => {
1067
+ // /new before any input is the first-session creation itself, not a switch.
1068
+ if (session === undefined) {
1069
+ ensureSession(mode)
1070
+ return
1071
+ }
1072
+ const nextCwd = session.header.cwd ?? cwd
1073
+ const id = `session-${randomUUID()}`
1074
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
1075
+ }
1076
+
1077
+ const switchSession = (row: SessionRow): void => {
1078
+ if (!row.resumable) {
1079
+ bridge.notify('subagent conversations are read-only', 'warning')
1080
+ return
1081
+ }
1082
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
1083
+ }
1084
+
1085
+ const cancelSessionSwitch = (): boolean => {
1086
+ return switchQueue.cancel()
1087
+ }
1088
+
1089
+ const appElement = (): ReturnType<typeof createElement> => {
1090
+ // A bare launch mounts with pending/default model, mode, and permission
1091
+ // facts until the first input composes the real session. These choices stay
1092
+ // process-local and create no durable state before that composition.
1093
+ const sessionCwd = session?.header.cwd ?? cwd
1094
+ const currentView = store.getView()
1095
+ const model = currentView.model !== ''
1096
+ ? currentView.model
1097
+ : pendingSelection !== undefined
1098
+ ? `${pendingSelection.provider}/${pendingSelection.model}`
1099
+ : `${defaults.provider}/${defaults.model}`
1100
+ const effort = resolveEffectiveSelection(
1101
+ active?.selection.picked ?? pendingSelection,
1102
+ session?.requestHeader()?.config,
1103
+ defaults,
1104
+ ).reasoningEffort
1105
+ const permission = permissionPresets === undefined
1106
+ ? currentView.permission
1107
+ : effectivePermission(permissionPresets, session, pendingPermission)
1108
+ return createElement(App, {
1109
+ key: session?.id ?? 'pending',
1110
+ store,
1111
+ approval,
1112
+ questions,
1113
+ commands,
1114
+ skills,
1115
+ model,
1116
+ effort,
1117
+ cwd: basename(sessionCwd),
1118
+ workspaceRoot: sessionCwd,
1119
+ branch: gitBranch(sessionCwd),
1120
+ sessionId: session === undefined ? '' : session.id.slice(-8),
1121
+ resumed: active?.resumed ?? false,
1122
+ mode: active?.mode ?? pendingMode ?? presets.defaultId,
1123
+ permission,
1124
+ dispatch,
1125
+ steer,
1126
+ interrupt,
1127
+ quit,
1128
+ loadModels: () => loadModelDirectory(ctx),
1129
+ loadModelProviders: () => loadProviderSettings(ctx),
1130
+ subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1131
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1132
+ unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1133
+ removeModelProvider: target => removeProviderSettings(ctx, target),
1134
+ loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
1135
+ cyclePermission,
1136
+ setPermission: setPermissionAction,
1137
+ selectModel,
1138
+ exportTranscript,
1139
+ renameTitle,
1140
+ loadPresets: () => presets.list(),
1141
+ switchMode: switchModeAction,
1142
+ loadPermissions: () => permissionPresets === undefined
1143
+ ? Promise.reject(new Error('permission presets are not mounted in this composition'))
1144
+ : Promise.resolve(listPermissionRows(permissionPresets)),
1145
+ createSession,
1146
+ loadSessions,
1147
+ loadSessionTranscript,
1148
+ switchSession,
1149
+ cancelSessionSwitch,
1150
+ loadPlugins: () => listPluginRows(ctx),
1151
+ statusline: statuslineItems,
1152
+ saveStatusline,
1153
+ saveTheme,
1154
+ history: inputHistory,
1155
+ recordHistory,
1156
+ cancelQueued,
1157
+ onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
1158
+ })
1159
+ }
1160
+
1161
+ const renderCurrent = (): void => {
1162
+ mountRef.current?.rerender(appElement())
1163
+ }
1164
+
1165
+ mountRef.current = io.mount(appElement())
1166
+
1167
+ // A corrupt statusline config must not vanish silently: surface it once
1168
+ // the notice channel is live, after the first frame settles.
1169
+ if (statuslineWarning !== undefined) {
1170
+ setTimeout(() => {
1171
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
1172
+ }, 50)
1173
+ }
1174
+ // Same one-shot surface for a corrupt theme file (dark fallback stays live).
1175
+ if (themeWarning !== undefined) {
1176
+ setTimeout(() => {
1177
+ bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
1178
+ }, 50)
1179
+ }
1180
+ }
1181
+
1182
+ /**
1183
+ * Mount the interactive terminal driver.
1184
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
1185
+ * @param config - validated startup config resolved from the tuiStartup provider.
1186
+ */
1187
+ export function apply(ctx: Context, config: Config): void {
1188
+ // The CLI validated --theme at parse time; the loose config schema falls
1189
+ // back to dark for anything unexpected.
1190
+ const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
1191
+ const startup: TuiStartup =
1192
+ config.startup.kind === 'resume' && config.startup.sessionId !== undefined
1193
+ ? { kind: 'resume', sessionId: config.startup.sessionId, ...(theme === undefined ? {} : { theme }) }
1194
+ : config.startup.kind === 'latest'
1195
+ ? { kind: 'latest', ...(theme === undefined ? {} : { theme }) }
1196
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
1197
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
1198
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
1199
+ // Read through the global service store, not the property proxy: appExit is
1200
+ // an optional host value, never an injected dependency.
1201
+ const exit = ctx.get('appExit')
1202
+ if (exit === undefined) {
1203
+ throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
1204
+ }
1205
+ const io: TuiIo = { mount: internals.mount, exit }
1206
+ void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
1207
+ }