dsh-code 0.6.0 → 0.7.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.
package/src/index.ts CHANGED
@@ -1,900 +1,964 @@
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
- renderCurrent()
527
- const queued = pendingInputs.splice(0)
528
- for (const item of queued) deliverLine(item.text, item.mode)
529
- })().catch((error: unknown) => {
530
- pendingInputs.length = 0
531
- bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
532
- }).finally(() => {
533
- creating = undefined
534
- })
535
- creating = attempt
536
- }
537
-
538
- /** Deliver one readable line to the agent, expanding session mentions first. */
539
- const send = (text: string, mode: 'followup' | 'steer'): void => {
540
- const line = text.trim()
541
- if (line === '') return
542
- if (session === undefined) {
543
- pendingInputs.push({ text: line, mode })
544
- ensureSession()
545
- return
546
- }
547
- deliverLine(line, mode)
548
- }
549
-
550
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
551
- const dispatch = (text: string): void => {
552
- send(text, 'followup')
553
- }
554
-
555
- /**
556
- * Submit steering: a running driver consumes the text at its next step
557
- * boundary (the inbox delivers between steps); an idle driver just starts
558
- * a turn, so this doubles as the busy-state submit path.
559
- */
560
- const steer = (text: string): void => {
561
- send(text, 'steer')
562
- }
563
-
564
- /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
565
- const interrupt = (): boolean => {
566
- if (agent === undefined || agent.status !== 'running') return false
567
- try {
568
- agent.cancel({ kind: 'user' })
569
- bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
570
- return true
571
- } catch (error: unknown) {
572
- bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
573
- return false
574
- }
575
- }
576
-
577
- /**
578
- * Cycle to the next permission preset (Shift+Tab, the Claude-Code
579
- * permission-mode convention mapped onto dsh presets). A session in a
580
- * custom knob state wraps to the first declared preset.
581
- */
582
- const cyclePermission = (): string => {
583
- if (session === undefined) throw new Error('no session yet — submit a message to start')
584
- const service = ctx.get('permissionPresets') as
585
- | {
586
- names: readonly string[]
587
- current(events: readonly SessionEvent[]): string
588
- set(target: Session, preset: string): void
589
- }
590
- | undefined
591
- if (service === undefined || service.names.length === 0) {
592
- bridge.notify('permission presets are not mounted in this composition', 'warning')
593
- return ''
594
- }
595
- const at = service.names.indexOf(service.current(session.events))
596
- const next = service.names[(at + 1) % service.names.length] ?? ''
597
- if (next === '') return ''
598
- try {
599
- service.set(session, next)
600
- return next
601
- } catch (error: unknown) {
602
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
603
- return ''
604
- }
605
- }
606
-
607
- /** Apply one /model selection: takes effect from the next assembled step. */
608
- const selectModel = (row: ModelRow): string => {
609
- if (active === undefined) throw new Error('no session yet — submit a message to start')
610
- active.selection.picked = { provider: row.provider, model: row.model }
611
- return `${row.provider}/${row.model}`
612
- }
613
-
614
- /**
615
- * Export the folded transcript to a markdown file (/export). The default
616
- * target sits beside the session's cwd so the file lands in the user's
617
- * workspace; an absolute or cwd-relative argument overrides it.
618
- */
619
- const exportTranscript = async (argument: string): Promise<void> => {
620
- if (session === undefined) {
621
- bridge.notify('no session yet — submit a message to start', 'warning')
622
- return
623
- }
624
- const wanted = argument.trim()
625
- const sessionCwd = session.header.cwd ?? cwd
626
- const defaultName = `dsh-session-${session.id.slice(-8)}.md`
627
- const target = wanted === ''
628
- ? join(sessionCwd, defaultName)
629
- : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
630
- ? wanted
631
- : join(sessionCwd, wanted)
632
- const markdown = buildExportMarkdown(store.getView(), session.id)
633
- try {
634
- await writeFileAsync(target, `${markdown}\n`, 'utf8')
635
- bridge.notify(`exported to ${target}`)
636
- } catch (error: unknown) {
637
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
638
- }
639
- }
640
-
641
- /**
642
- * Rename the session (/title): a user title pins the session and stops
643
- * automatic generation (the service's own contract). The appended
644
- * `session/title` event flows back through the store into the status line.
645
- */
646
- const renameTitle = (argument: string): string => {
647
- const title = argument.trim()
648
- if (title === '') return 'usage: /title <text>'
649
- if (session === undefined) return 'no session yet — submit a message to start'
650
- const service = ctx.get('sessionTitle')
651
- if (service === undefined) return 'session titles are unavailable in this profile'
652
- try {
653
- service.rename(session, title)
654
- return `title → ${title}`
655
- } catch (error: unknown) {
656
- return `rename failed: ${error instanceof Error ? error.message : String(error)}`
657
- }
658
- }
659
-
660
- const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
661
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
662
- const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
663
- // Titles are the expensive fold. Fetch only the first bounded picker page;
664
- // navigation/filter changes trigger a fresh, cancellable observation.
665
- const page = projected.slice(0, 32)
666
- if (page.length === 0) return projected
667
- const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
668
- return mergeSessionTitles(projected, observations)
669
- }
670
-
671
- const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
672
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
673
- const snapshot = await sessionQuery.readSession(id, signal)
674
- return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
675
- }
676
-
677
- const switchModeAction = async (id: string): Promise<string> => {
678
- if (id === '') throw new Error('usage: /mode <preset>')
679
- const currentAgent = agent
680
- const currentActive = active
681
- if (currentAgent === undefined || currentActive === undefined) {
682
- throw new Error('no session yet — submit a message to start')
683
- }
684
- const preset = await switchPreset(presets, currentAgent, id)
685
- currentActive.mode = preset.id
686
- commands.setAgent(currentAgent)
687
- skills.setAgent(currentAgent)
688
- renderCurrent()
689
- return preset.id
690
- }
691
-
692
- interface PendingSwitch { readonly target: Target; readonly label: string }
693
-
694
- const activate = async (nextTarget: Target): Promise<void> => {
695
- const previous = active
696
- const next = await prepare(nextTarget)
697
- active = next
698
- agent = next.agent
699
- session = next.session
700
- store = next.store
701
- mentions = next.mentions
702
- commands.setAgent(agent)
703
- skills.setAgent(agent)
704
- try {
705
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
706
- renderCurrent()
707
- } catch (error: unknown) {
708
- active = previous
709
- agent = previous?.agent
710
- session = previous?.session
711
- store = previous === undefined ? createTranscriptStore() : previous.store
712
- mentions = previous?.mentions
713
- if (agent !== undefined) commands.setAgent(agent)
714
- if (agent !== undefined) skills.setAgent(agent)
715
- await next.handle.dispose()
716
- renderCurrent()
717
- throw error
718
- }
719
- // No previous session (a bare launch switched straight into a resume):
720
- // nothing to flush or dispose, so just confirm the activation.
721
- if (previous === undefined) {
722
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
723
- return
724
- }
725
- let cleanupWarning: string | undefined
726
- try {
727
- await sessions.flush(previous.session)
728
- } catch (error: unknown) {
729
- cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
730
- }
731
- try {
732
- await previous.handle.dispose()
733
- } catch (error: unknown) {
734
- cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
735
- }
736
- bridge.notify(cleanupWarning === undefined
737
- ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
738
- : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
739
- cleanupWarning === undefined ? 'info' : 'warning')
740
- }
741
-
742
- const switchQueue = new SessionSwitchQueue<PendingSwitch>(
743
- async request => { if (!quitting) await activate(request.target) },
744
- error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
745
- )
746
-
747
- const requestSwitch = (request: PendingSwitch): void => {
748
- if (session === undefined) {
749
- // No session yet (a bare launch using /resume before any input): activate
750
- // the target directly — there is no running turn to wait on and nothing
751
- // to flush.
752
- void activate(request.target).catch((error: unknown) => {
753
- bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
754
- })
755
- return
756
- }
757
- if (request.target.sessionId === session.id) {
758
- bridge.notify('that session is already active', 'warning')
759
- return
760
- }
761
- const outcome = switchQueue.request(agent!, request)
762
- if (outcome === 'queued') {
763
- bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
764
- }
765
- }
766
-
767
- const resolveResumeId = async (wanted: string): Promise<string> => {
768
- if (wanted === '') throw new Error('usage: /resume <id|prefix>')
769
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
770
- const records = await sessionQuery.listSessions()
771
- const exact = records.filter(record => record.header.id === wanted)
772
- const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
773
- if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
774
- if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
775
- if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
776
- throw new Error('subagent conversations are read-only in /resume; resume a root session')
777
- }
778
- if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
779
- throw new Error('that session is already live in another owner')
780
- }
781
- return matches[0]!.header.id
782
- }
783
-
784
- const requestResume = (wanted: string): void => {
785
- void resolveResumeId(wanted).then(id => {
786
- requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
787
- }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
788
- }
789
-
790
- const createSession = (mode?: string): void => {
791
- // /new before any input is the first-session creation itself, not a switch.
792
- if (session === undefined) {
793
- ensureSession(mode)
794
- return
795
- }
796
- const nextCwd = session.header.cwd ?? cwd
797
- const id = `session-${randomUUID()}`
798
- requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
799
- }
800
-
801
- const switchSession = (row: SessionRow): void => {
802
- if (!row.resumable) {
803
- bridge.notify('subagent conversations are read-only', 'warning')
804
- return
805
- }
806
- requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
807
- }
808
-
809
- const cancelSessionSwitch = (): boolean => {
810
- return switchQueue.cancel()
811
- }
812
-
813
- const appElement = (): ReturnType<typeof createElement> => {
814
- // A bare launch mounts with placeholder facts until the first input
815
- // composes a real session: empty session id/mode, the deployment default
816
- // model, and the working directory's basename. `status.ts` drops empty
817
- // mode/sessionId, so the bar renders only the identity it actually has.
818
- const sessionCwd = session?.header.cwd ?? cwd
819
- const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
820
- return createElement(App, {
821
- key: session?.id ?? 'pending',
822
- store,
823
- approval,
824
- questions,
825
- commands,
826
- skills,
827
- model,
828
- cwd: basename(sessionCwd),
829
- workspaceRoot: sessionCwd,
830
- branch: gitBranch(sessionCwd),
831
- sessionId: session === undefined ? '' : session.id.slice(-8),
832
- resumed: active?.resumed ?? false,
833
- mode: active?.mode ?? '',
834
- dispatch,
835
- steer,
836
- interrupt,
837
- quit,
838
- loadModels: () => loadModelDirectory(ctx),
839
- loadMentions: mentions === undefined
840
- ? () => Promise.resolve<readonly MentionCandidate[]>([])
841
- : mentions.candidates,
842
- cyclePermission,
843
- selectModel,
844
- exportTranscript,
845
- renameTitle,
846
- loadPresets: () => presets.list(),
847
- switchMode: switchModeAction,
848
- createSession,
849
- loadSessions,
850
- loadSessionTranscript,
851
- switchSession,
852
- cancelSessionSwitch,
853
- loadPlugins: () => listPluginRows(ctx),
854
- statusline: statuslineItems,
855
- saveStatusline,
856
- history: inputHistory,
857
- recordHistory,
858
- cancelQueued,
859
- onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
860
- })
861
- }
862
-
863
- const renderCurrent = (): void => {
864
- mountRef.current?.rerender(appElement())
865
- }
866
-
867
- mountRef.current = io.mount(appElement())
868
-
869
- // A corrupt statusline config must not vanish silently: surface it once
870
- // the notice channel is live, after the first frame settles.
871
- if (statuslineWarning !== undefined) {
872
- setTimeout(() => {
873
- bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
874
- }, 50)
875
- }
876
- }
877
-
878
- /**
879
- * Mount the interactive terminal driver.
880
- * @param ctx - plugin context carrying core services and the launcher-provided exit request.
881
- * @param config - validated startup config resolved from the tuiStartup provider.
882
- */
883
- export function apply(ctx: Context, config: Config): void {
884
- const startup: TuiStartup =
885
- config.startup.kind === 'resume' && config.startup.sessionId !== undefined
886
- ? { kind: 'resume', sessionId: config.startup.sessionId }
887
- : config.startup.kind === 'latest'
888
- ? { kind: 'latest' }
889
- : config.startup.kind === 'named' && config.startup.sessionId !== undefined
890
- ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
891
- : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
892
- // Read through the global service store, not the property proxy: appExit is
893
- // an optional host value, never an injected dependency.
894
- const exit = ctx.get('appExit')
895
- if (exit === undefined) {
896
- throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
897
- }
898
- const io: TuiIo = { mount: internals.mount, exit }
899
- void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
900
- }
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 { createMentions, 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 { parseThemeName, setTheme, type ThemeName } from './theme.ts'
50
+ import {
51
+ mergeSessionTitles,
52
+ projectSessionRows,
53
+ type SessionDirectoryOptions,
54
+ type SessionQueryService,
55
+ type SessionRow,
56
+ } from './session-directory.ts'
57
+
58
+ /** Stable Cordis plugin name. */
59
+ export const name = 'tui-runner'
60
+
61
+ /** Core services required before the interactive session can start. */
62
+ export const inject = ['agentDefaultModel', 'agents', 'sessions']
63
+
64
+ /** Plugin config: the startup resolved from this app's injected provider service. */
65
+ export interface Config {
66
+ /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
67
+ startup: { kind: string; sessionId?: string; mode?: string; theme?: string }
68
+ }
69
+
70
+ export const Config: z<Config> = z.object({
71
+ startup: z.object({
72
+ kind: z.string().required(),
73
+ sessionId: z.string(),
74
+ mode: z.string(),
75
+ theme: z.string(),
76
+ }),
77
+ })
78
+
79
+ /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
80
+ interface TuiIo {
81
+ mount: typeof internals.mount
82
+ exit(code: number): void
83
+ }
84
+
85
+ /** Report an unexpected direct-driver failure and request a failing exit. */
86
+ function fail(io: TuiIo, error: unknown): void {
87
+ internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
88
+ io.exit(1)
89
+ }
90
+
91
+ /**
92
+ * Resolve the working directory's git branch for the status line.
93
+ * @param cwd - the session's working directory.
94
+ * @returns the branch name, or '' outside a repository or on a detached HEAD.
95
+ */
96
+ function gitBranch(cwd: string): string {
97
+ try {
98
+ const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
99
+ return ref?.[1] ?? ''
100
+ } catch {
101
+ // Only the single HEAD read is attempted, so the sole reachable failure is
102
+ // a missing repository (or unreadable HEAD file): the branch group drops out.
103
+ return ''
104
+ }
105
+ }
106
+
107
+ /** The session identity this invocation will run, plus whether it is resumed. */
108
+ interface Target {
109
+ sessionId: string
110
+ resume: boolean
111
+ mode?: string
112
+ cwd?: string
113
+ }
114
+
115
+ /**
116
+ * Resolve the invocation's target session against the persisted headers.
117
+ * @param startup - the parsed startup flags.
118
+ * @param persistence - the persistence service; required for resume/latest.
119
+ * @param cwd - the working directory `--continue` filters by.
120
+ * @returns the target identity.
121
+ * @throws with a user-facing message when the flags name nothing resolvable.
122
+ */
123
+ async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
124
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
125
+ if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
126
+ if (persistence === undefined) {
127
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
128
+ }
129
+ const headers: readonly SessionHeader[] = await persistence.list()
130
+ if (startup.kind === 'resume') {
131
+ const wanted = startup.sessionId
132
+ const exact = headers.filter(header => header.id === wanted)
133
+ const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
134
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
135
+ if (matches.length > 1) {
136
+ throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
137
+ }
138
+ return { sessionId: matches[0]!.id, resume: true }
139
+ }
140
+ // --continue: the newest persisted session whose header pins this cwd.
141
+ const local = headers
142
+ .filter(header => header.cwd === cwd)
143
+ .sort((left, right) => right.createdAt - left.createdAt)
144
+ if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
145
+ return { sessionId: local[0]!.id, resume: true }
146
+ }
147
+
148
+ /**
149
+ * Resolve a bounded command preview for one pending approval: the request
150
+ * contract carries no arguments, so the bar self-serves from the transcript
151
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
152
+ * @param events - the transcript entries to search.
153
+ * @param callId - the tool call the question is about, when the asker had one.
154
+ * @param toolName - the tool the question is about.
155
+ * @returns a bounded preview line, '' when nothing useful resolves.
156
+ */
157
+ function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
158
+ if (callId === undefined) return ''
159
+ const entry = events.find(candidate =>
160
+ candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
161
+ if (entry === undefined) return ''
162
+ const args = (entry as { arguments?: string }).arguments ?? ''
163
+ return toolArgumentsPreview(args, toolName)
164
+ }
165
+
166
+ /** The runner's connection between the React app and the process side. */
167
+ interface AppBridge {
168
+ /** Post one local notice line (feedback the transcript does not carry). */
169
+ notify(text: string, tone?: NoticeTone): void
170
+ }
171
+
172
+ /**
173
+ * Run the interactive terminal session: resolve the target session, create or
174
+ * resume one Agent, mount the app, and keep the process alive until the user
175
+ * quits.
176
+ * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
177
+ * @param startup - the parsed invocation flags.
178
+ * @param io - process-facing effects.
179
+ */
180
+ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
181
+ // Loader siblings mount concurrently. Await the complete application before
182
+ // creating an Agent so its scoped tools and adapters are not half-composed.
183
+ await ctx.get('loader')?.await()
184
+ const agents = ctx.get('agents')
185
+ const defaultModel = ctx.get('agentDefaultModel')
186
+ const sessions = ctx.get('sessions')
187
+ const persistence = ctx.get('sessionPersistence')
188
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
189
+ // Early process shutdown can dispose the tree while settlement is pending.
190
+ if (agents === undefined || defaultModel === undefined || sessions === undefined) return
191
+
192
+ const cwd = process.cwd()
193
+ const defaults = defaultModel.currentSelection()
194
+ const presets = agentPresetsFrom(ctx)
195
+ if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
196
+
197
+ // A bare fresh launch stays transient: no Agent or session is composed, and
198
+ // nothing is persisted, until the user's first real input. Explicit flags
199
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
200
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
201
+
202
+ interface ActiveSession {
203
+ handle: AgentHandle
204
+ agent: Agent
205
+ session: Session
206
+ store: ReturnType<typeof createTranscriptStore>
207
+ mentions: MentionsApi
208
+ mode: string
209
+ selection: { picked?: ModelSelection }
210
+ resumed: boolean
211
+ }
212
+
213
+ /** Prepare a complete next session before disturbing the currently visible one. */
214
+ const prepare = async (next: Target): Promise<ActiveSession> => {
215
+ const nextCwd = next.cwd ?? cwd
216
+ // A bare launch can pick a model before any session exists: the process
217
+ // keeps that explicit choice and every prepared session starts from it
218
+ // (the documented precedence: explicit pick > session header > default).
219
+ const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
220
+ ? {}
221
+ : { picked: pendingSelection }
222
+ let mode = next.mode
223
+ if (!next.resume) mode = (await presets.resolve(mode)).id
224
+ const setup = async (agentCtx: Context): Promise<void> => {
225
+ const sessionPreset = next.resume
226
+ ? resolvePreset(agentCtx.agent!.session)
227
+ : mode
228
+ const mounted = await presets.mount(agentCtx, sessionPreset)
229
+ mode = mounted.id
230
+ const selection: ModelSelectionRef = {
231
+ get current(): ModelSelection | undefined {
232
+ return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, defaults)
233
+ },
234
+ set current(value: ModelSelection | undefined) { selectionState.picked = value },
235
+ assembled: undefined,
236
+ }
237
+ installModelSelection(agentCtx, selection)
238
+ }
239
+ const handle = next.resume
240
+ ? await agents.resume({
241
+ resumeSessionId: SessionId(next.sessionId),
242
+ agentOptions: { provider: defaults.provider, model: defaults.model },
243
+ setup,
244
+ })
245
+ : await agents.create({
246
+ sessionId: SessionId(next.sessionId),
247
+ meta: { cwd: nextCwd, agentPreset: mode },
248
+ agentOptions: { provider: defaults.provider, model: defaults.model },
249
+ setup,
250
+ })
251
+ const session = handle.agent.session
252
+ const sessionCwd = session.header.cwd ?? nextCwd
253
+ return {
254
+ handle,
255
+ agent: handle.agent,
256
+ session,
257
+ store: createTranscriptStore(session.events),
258
+ mentions: createMentions(ctx, handle.agent, sessionCwd),
259
+ mode: mode ?? 'standard',
260
+ selection: selectionState,
261
+ resumed: next.resume,
262
+ }
263
+ }
264
+
265
+ let active: ActiveSession | undefined
266
+ let agent: Agent | undefined
267
+ let session: Session | undefined
268
+ let store: TranscriptStore = createTranscriptStore()
269
+ // File-only mentions from the start: `@` completion works on a bare launch
270
+ // (no session yet); the prepare/activate paths replace this with the full
271
+ // agent-scoped instance that also resolves session references.
272
+ let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
273
+ /** Explicit model pick made before any session exists (a bare launch). */
274
+ let pendingSelection: ModelSelection | undefined
275
+
276
+ if (!lazy) {
277
+ const target = await resolveTarget(startup, persistence, cwd)
278
+ const prepared = await prepare(target)
279
+ active = prepared
280
+ agent = prepared.agent
281
+ session = prepared.session
282
+ store = prepared.store
283
+ mentions = prepared.mentions
284
+ }
285
+
286
+ // Seed the transcript from the full session log: constructor seeds never
287
+ // fire on `session/event`, so a resumed session paints its history once
288
+ // before the first render. The handler reads the current session/store, so
289
+ // the deferred first session of a bare launch is covered by the same feed.
290
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
291
+ if (session !== undefined && subject.id === session.id) store.apply(event)
292
+ })
293
+
294
+ const commands: CommandsView = watchCommands(ctx)
295
+ if (agent !== undefined) commands.setAgent(agent)
296
+
297
+ const skills: SkillsView = watchSkills(ctx)
298
+ if (agent !== undefined) skills.setAgent(agent)
299
+
300
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
301
+ // claimed, every other ask falls through to the fail-closed waterfall. The
302
+ // owner predicate is empty until the first session exists.
303
+ const approval: ApprovalStore = mountApprovalAnswerer(
304
+ ctx,
305
+ candidate => agent !== undefined && candidate.id === agent.id,
306
+ request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
307
+ )
308
+
309
+ // ask_user_question provider: the single UI provider on the shared service,
310
+ // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
311
+ // through this same pipe.
312
+ const questions: QuestionStore = mountQuestionProvider(ctx)
313
+
314
+ // The bridge the React app registers on mount: local notices from the
315
+ // process side (unknown commands, switch confirmations, cancels).
316
+ const bridge: AppBridge = { notify: () => {} }
317
+
318
+ // /statusline persistence: one user-level JSON file under the DSH home.
319
+ // Missing file means defaults; a corrupt file degrades to defaults with a
320
+ // surfaced warning (the customization is user-authored, never silent).
321
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
322
+ let statuslineWarning: string | undefined
323
+ let statuslineItems: readonly string[] = []
324
+ try {
325
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
326
+ } catch (error) {
327
+ statuslineItems = parseStatuslineItems(undefined)
328
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
329
+ statuslineWarning = error instanceof Error ? error.message : String(error)
330
+ }
331
+ }
332
+ const saveStatusline = (items: readonly string[]): void => {
333
+ statuslineItems = [...items]
334
+ // The config directory may not exist on a first save; create it before
335
+ // the write so a fresh install persists customizations.
336
+ void mkdir(dirname(statuslinePath), { recursive: true })
337
+ .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
338
+ .catch((writeError: unknown) => {
339
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
340
+ })
341
+ }
342
+
343
+ // /theme persistence: one user-level JSON file under the DSH home, mirroring
344
+ // the statusline file. A missing file means the dark default; a corrupt file
345
+ // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
346
+ // auto detection > dark (auto detection itself is a later enhancement and
347
+ // currently falls back to dark inside theme.ts).
348
+ const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
349
+ let themeWarning: string | undefined
350
+ if (startup.theme === undefined) {
351
+ try {
352
+ setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
353
+ } catch (error) {
354
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
355
+ themeWarning = error instanceof Error ? error.message : String(error)
356
+ }
357
+ }
358
+ } else {
359
+ setTheme(startup.theme)
360
+ }
361
+ const saveTheme = (name: ThemeName): void => {
362
+ setTheme(name)
363
+ void mkdir(dirname(themePath), { recursive: true })
364
+ .then(() => writeFileAsync(themePath, JSON.stringify({ theme: name }, null, 2) + '\n', 'utf8'))
365
+ .catch((writeError: unknown) => {
366
+ bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
367
+ })
368
+ }
369
+
370
+ // Global input recall (Codex composer-history contract): one JSONL file
371
+ // under the DSH home. A missing file means an empty history; unreadable or
372
+ // corrupt content degrades to the valid lines it could parse, silently —
373
+ // recall is a convenience surface, never a gate.
374
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
375
+ let inputHistory: readonly string[] = []
376
+ try {
377
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
378
+ } catch {
379
+ inputHistory = []
380
+ }
381
+ const recordHistory = (text: string): void => {
382
+ if (text === '') return
383
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
384
+ // A missing file on the first save is not an error: start from empty.
385
+ let current = ''
386
+ try {
387
+ current = readFileSync(historyPath, 'utf8')
388
+ } catch {
389
+ current = ''
390
+ }
391
+ void mkdir(dirname(historyPath), { recursive: true })
392
+ .then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
393
+ .catch((writeError: unknown) => {
394
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
395
+ })
396
+ }
397
+
398
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
399
+ const cancelQueued = (messageId: string): void => {
400
+ if (agent === undefined) return
401
+ try {
402
+ if (agent.inbox.remove(MessageId(messageId))) {
403
+ bridge.notify('queued message cancelled')
404
+ }
405
+ } catch (error: unknown) {
406
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
407
+ }
408
+ }
409
+
410
+ // The mount handle lives in a box: quit closes over it, while the mount
411
+ // itself is created after quit (the App element needs quit as a prop).
412
+ const mountRef: { current?: TuiMount } = {}
413
+ let quitting = false
414
+ const quit = (): void => {
415
+ if (quitting) return
416
+ quitting = true
417
+ switchQueue.cancel()
418
+ off()
419
+ mountRef.current?.unmount()
420
+ // A bare launch that exits before the first input has no session: exit
421
+ // cleanly without flushing or disposing anything.
422
+ const currentSession = session
423
+ const currentActive = active
424
+ if (currentSession === undefined || currentActive === undefined) {
425
+ io.exit(0)
426
+ return
427
+ }
428
+ void sessions.flush(currentSession)
429
+ .catch((flushError: unknown) => {
430
+ // The session log already carries every durable event; a failed flush
431
+ // must not trap the user in a dead terminal, so report and still exit.
432
+ internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
433
+ })
434
+ .then(() => currentActive.handle.dispose())
435
+ .catch((disposeError: unknown) => {
436
+ internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
437
+ })
438
+ .then(() => { io.exit(0) })
439
+ }
440
+
441
+ /** Run one slash line through the command registry (closed namespace). */
442
+ const runSlash = (line: string): void => {
443
+ const currentAgent = agent
444
+ if (currentAgent === undefined) return
445
+ if (line.startsWith('/mode ')) {
446
+ void switchModeAction(line.slice(6).trim())
447
+ return
448
+ }
449
+ if (line.startsWith('/resume ')) {
450
+ requestResume(line.slice(8).trim())
451
+ return
452
+ }
453
+ const registry = ctx.get('commands')
454
+ if (registry === undefined) {
455
+ bridge.notify('no command registry is mounted in this composition', 'error')
456
+ return
457
+ }
458
+ const controller = new AbortController()
459
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
460
+ if (execution === undefined) {
461
+ // No command owns this line: send it verbatim so a user-invocable
462
+ // skill gesture (`/skill-name`) reaches the host's tool-skill
463
+ // pre-step injection the web composer's same fall-through.
464
+ try {
465
+ currentAgent.followup(createUserMessage({
466
+ content: [{ type: 'text', text: line }],
467
+ source: { kind: 'user' },
468
+ }))
469
+ } catch (error: unknown) {
470
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
471
+ }
472
+ }
473
+ }, (error: unknown) => {
474
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
475
+ })
476
+ }
477
+
478
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
479
+ const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
480
+ const currentAgent = agent!
481
+ const currentMentions = mentions!
482
+ // The command registry is a closed namespace: slash lines run out of
483
+ // band and never reach the model through this path (steering keeps the
484
+ // registry out of the inbox, so slash lines steer as literal text).
485
+ if (isSlashLine(line) && mode === 'followup') {
486
+ runSlash(line)
487
+ return
488
+ }
489
+ let parsed: ReturnType<MentionsApi['parse']>
490
+ try {
491
+ parsed = currentMentions.parse(line)
492
+ } catch (error: unknown) {
493
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
494
+ return
495
+ }
496
+ const deliver = (readable: string, context?: UserMessage): void => {
497
+ // Session snapshots ride the inbox as model-facing context ahead of
498
+ // the readable message (upstream README wiring: inject before the
499
+ // followup/steer that wakes the driver).
500
+ try {
501
+ if (context !== undefined) currentAgent.inject(context)
502
+ const message = createUserMessage({
503
+ content: [{ type: 'text', text: readable }],
504
+ source: { kind: 'user' },
505
+ })
506
+ if (mode === 'steer') {
507
+ // The queued message is visible as a pending transcript row (the
508
+ // web queue-mirror contract); no notice noise on the happy path.
509
+ currentAgent.steer(message)
510
+ } else {
511
+ currentAgent.followup(message)
512
+ }
513
+ } catch (error: unknown) {
514
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
515
+ }
516
+ }
517
+ if (parsed.references.length === 0) {
518
+ deliver(parsed.text)
519
+ return
520
+ }
521
+ const controller = new AbortController()
522
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
523
+ deliver(prepared.text, prepared.additionalContext)
524
+ }, (error: unknown) => {
525
+ if (controller.signal.aborted) return
526
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
527
+ })
528
+ }
529
+
530
+ // Deferred first-session creation for a bare launch: the session is composed
531
+ // only when the user submits real input (or /new), and every line that
532
+ // arrives during creation is delivered in order afterwards. A creation
533
+ // failure reports and clears the queue, leaving the transient state ready
534
+ // for the next attempt.
535
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
536
+ let creating: Promise<void> | undefined
537
+ const ensureSession = (mode?: string): void => {
538
+ if (creating !== undefined) return
539
+ const attempt = (async () => {
540
+ const next = await prepare({
541
+ sessionId: `session-${randomUUID()}`,
542
+ resume: false,
543
+ ...(mode === undefined ? {} : { mode }),
544
+ })
545
+ if (quitting) {
546
+ void next.handle.dispose().catch(() => {})
547
+ return
548
+ }
549
+ active = next
550
+ agent = next.agent
551
+ session = next.session
552
+ store = next.store
553
+ mentions = next.mentions
554
+ commands.setAgent(agent)
555
+ skills.setAgent(agent)
556
+ // The App mounts with a placeholder key until the first input; the
557
+ // key-change remount below must start from a clean screen or the ghost
558
+ // static header stays visible above the new one (same source-backed
559
+ // clear the session-switch path performs).
560
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
561
+ renderCurrent()
562
+ const queued = pendingInputs.splice(0)
563
+ for (const item of queued) deliverLine(item.text, item.mode)
564
+ })().catch((error: unknown) => {
565
+ pendingInputs.length = 0
566
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
567
+ }).finally(() => {
568
+ creating = undefined
569
+ })
570
+ creating = attempt
571
+ }
572
+
573
+ /** Deliver one readable line to the agent, expanding session mentions first. */
574
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
575
+ const line = text.trim()
576
+ if (line === '') return
577
+ if (session === undefined) {
578
+ pendingInputs.push({ text: line, mode })
579
+ ensureSession()
580
+ return
581
+ }
582
+ deliverLine(line, mode)
583
+ }
584
+
585
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
586
+ const dispatch = (text: string): void => {
587
+ send(text, 'followup')
588
+ }
589
+
590
+ /**
591
+ * Submit steering: a running driver consumes the text at its next step
592
+ * boundary (the inbox delivers between steps); an idle driver just starts
593
+ * a turn, so this doubles as the busy-state submit path.
594
+ */
595
+ const steer = (text: string): void => {
596
+ send(text, 'steer')
597
+ }
598
+
599
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
600
+ const interrupt = (): boolean => {
601
+ if (agent === undefined || agent.status !== 'running') return false
602
+ try {
603
+ agent.cancel({ kind: 'user' })
604
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
605
+ return true
606
+ } catch (error: unknown) {
607
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
608
+ return false
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Cycle to the next permission preset (Shift+Tab, the Claude-Code
614
+ * permission-mode convention mapped onto dsh presets). A session in a
615
+ * custom knob state wraps to the first declared preset.
616
+ */
617
+ const cyclePermission = (): string => {
618
+ if (session === undefined) throw new Error('no session yet — submit a message to start')
619
+ const service = ctx.get('permissionPresets') as
620
+ | {
621
+ names: readonly string[]
622
+ current(events: readonly SessionEvent[]): string
623
+ set(target: Session, preset: string): void
624
+ }
625
+ | undefined
626
+ if (service === undefined || service.names.length === 0) {
627
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
628
+ return ''
629
+ }
630
+ const at = service.names.indexOf(service.current(session.events))
631
+ const next = service.names[(at + 1) % service.names.length] ?? ''
632
+ if (next === '') return ''
633
+ try {
634
+ service.set(session, next)
635
+ return next
636
+ } catch (error: unknown) {
637
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
638
+ return ''
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Apply one /model selection: takes effect from the next assembled step.
644
+ * The optional reasoning effort must be one the row advertises (the picker
645
+ * only offers those), so an unsupported value cannot reach the request
646
+ * pipeline; an absent effort restores the model's own default.
647
+ */
648
+ const selectModel = (row: ModelRow, effortId?: string): string => {
649
+ const selection = buildModelSelection(row, effortId)
650
+ if (active === undefined) {
651
+ // A bare launch has no session yet: keep the pick process-wide so the
652
+ // first composed session starts from it.
653
+ pendingSelection = selection
654
+ } else {
655
+ active.selection.picked = selection
656
+ }
657
+ return `${row.provider}/${row.model}`
658
+ }
659
+
660
+ /**
661
+ * Export the folded transcript to a markdown file (/export). The default
662
+ * target sits beside the session's cwd so the file lands in the user's
663
+ * workspace; an absolute or cwd-relative argument overrides it.
664
+ */
665
+ const exportTranscript = async (argument: string): Promise<void> => {
666
+ if (session === undefined) {
667
+ bridge.notify('no session yet submit a message to start', 'warning')
668
+ return
669
+ }
670
+ const wanted = argument.trim()
671
+ const sessionCwd = session.header.cwd ?? cwd
672
+ const defaultName = `dsh-session-${session.id.slice(-8)}.md`
673
+ const target = wanted === ''
674
+ ? join(sessionCwd, defaultName)
675
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
676
+ ? wanted
677
+ : join(sessionCwd, wanted)
678
+ const markdown = buildExportMarkdown(store.getView(), session.id)
679
+ try {
680
+ await writeFileAsync(target, `${markdown}\n`, 'utf8')
681
+ bridge.notify(`exported to ${target}`)
682
+ } catch (error: unknown) {
683
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Rename the session (/title): a user title pins the session and stops
689
+ * automatic generation (the service's own contract). The appended
690
+ * `session/title` event flows back through the store into the status line.
691
+ */
692
+ const renameTitle = (argument: string): string => {
693
+ const title = argument.trim()
694
+ if (title === '') return 'usage: /title <text>'
695
+ if (session === undefined) return 'no session yet — submit a message to start'
696
+ const service = ctx.get('sessionTitle')
697
+ if (service === undefined) return 'session titles are unavailable in this profile'
698
+ try {
699
+ service.rename(session, title)
700
+ return `title → ${title}`
701
+ } catch (error: unknown) {
702
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`
703
+ }
704
+ }
705
+
706
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
707
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
708
+ const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
709
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
710
+ // navigation/filter changes trigger a fresh, cancellable observation.
711
+ const page = projected.slice(0, 32)
712
+ if (page.length === 0) return projected
713
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
714
+ return mergeSessionTitles(projected, observations)
715
+ }
716
+
717
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
718
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
719
+ const snapshot = await sessionQuery.readSession(id, signal)
720
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
721
+ }
722
+
723
+ const switchModeAction = async (id: string): Promise<string> => {
724
+ if (id === '') throw new Error('usage: /mode <preset>')
725
+ const currentAgent = agent
726
+ const currentActive = active
727
+ if (currentAgent === undefined || currentActive === undefined) {
728
+ throw new Error('no session yet — submit a message to start')
729
+ }
730
+ const preset = await switchPreset(presets, currentAgent, id)
731
+ currentActive.mode = preset.id
732
+ commands.setAgent(currentAgent)
733
+ skills.setAgent(currentAgent)
734
+ renderCurrent()
735
+ return preset.id
736
+ }
737
+
738
+ interface PendingSwitch { readonly target: Target; readonly label: string }
739
+
740
+ const activate = async (nextTarget: Target): Promise<void> => {
741
+ const previous = active
742
+ const next = await prepare(nextTarget)
743
+ active = next
744
+ agent = next.agent
745
+ session = next.session
746
+ store = next.store
747
+ mentions = next.mentions
748
+ commands.setAgent(agent)
749
+ skills.setAgent(agent)
750
+ try {
751
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
752
+ renderCurrent()
753
+ } catch (error: unknown) {
754
+ active = previous
755
+ agent = previous?.agent
756
+ session = previous?.session
757
+ store = previous === undefined ? createTranscriptStore() : previous.store
758
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
759
+ if (agent !== undefined) commands.setAgent(agent)
760
+ if (agent !== undefined) skills.setAgent(agent)
761
+ await next.handle.dispose()
762
+ renderCurrent()
763
+ throw error
764
+ }
765
+ // No previous session (a bare launch switched straight into a resume):
766
+ // nothing to flush or dispose, so just confirm the activation.
767
+ if (previous === undefined) {
768
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
769
+ return
770
+ }
771
+ let cleanupWarning: string | undefined
772
+ try {
773
+ await sessions.flush(previous.session)
774
+ } catch (error: unknown) {
775
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
776
+ }
777
+ try {
778
+ await previous.handle.dispose()
779
+ } catch (error: unknown) {
780
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
781
+ }
782
+ bridge.notify(cleanupWarning === undefined
783
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
784
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
785
+ cleanupWarning === undefined ? 'info' : 'warning')
786
+ }
787
+
788
+ const switchQueue = new SessionSwitchQueue<PendingSwitch>(
789
+ async request => { if (!quitting) await activate(request.target) },
790
+ error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
791
+ )
792
+
793
+ const requestSwitch = (request: PendingSwitch): void => {
794
+ if (session === undefined) {
795
+ // No session yet (a bare launch using /resume before any input): activate
796
+ // the target directly there is no running turn to wait on and nothing
797
+ // to flush.
798
+ void activate(request.target).catch((error: unknown) => {
799
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
800
+ })
801
+ return
802
+ }
803
+ if (request.target.sessionId === session.id) {
804
+ bridge.notify('that session is already active', 'warning')
805
+ return
806
+ }
807
+ const outcome = switchQueue.request(agent!, request)
808
+ if (outcome === 'queued') {
809
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
810
+ }
811
+ }
812
+
813
+ const resolveResumeId = async (wanted: string): Promise<string> => {
814
+ if (wanted === '') throw new Error('usage: /resume <id|prefix>')
815
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
816
+ const records = await sessionQuery.listSessions()
817
+ const exact = records.filter(record => record.header.id === wanted)
818
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
819
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
820
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
821
+ if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
822
+ throw new Error('subagent conversations are read-only in /resume; resume a root session')
823
+ }
824
+ if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
825
+ throw new Error('that session is already live in another owner')
826
+ }
827
+ return matches[0]!.header.id
828
+ }
829
+
830
+ const requestResume = (wanted: string): void => {
831
+ void resolveResumeId(wanted).then(id => {
832
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
833
+ }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
834
+ }
835
+
836
+ const createSession = (mode?: string): void => {
837
+ // /new before any input is the first-session creation itself, not a switch.
838
+ if (session === undefined) {
839
+ ensureSession(mode)
840
+ return
841
+ }
842
+ const nextCwd = session.header.cwd ?? cwd
843
+ const id = `session-${randomUUID()}`
844
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
845
+ }
846
+
847
+ const switchSession = (row: SessionRow): void => {
848
+ if (!row.resumable) {
849
+ bridge.notify('subagent conversations are read-only', 'warning')
850
+ return
851
+ }
852
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
853
+ }
854
+
855
+ const cancelSessionSwitch = (): boolean => {
856
+ return switchQueue.cancel()
857
+ }
858
+
859
+ const appElement = (): ReturnType<typeof createElement> => {
860
+ // A bare launch mounts with placeholder facts until the first input
861
+ // composes a real session: empty session id/mode, the deployment default
862
+ // model, and the working directory's basename. `status.ts` drops empty
863
+ // mode/sessionId, so the bar renders only the identity it actually has.
864
+ const sessionCwd = session?.header.cwd ?? cwd
865
+ const model = store.getView().model !== ''
866
+ ? store.getView().model
867
+ : pendingSelection !== undefined
868
+ ? `${pendingSelection.provider}/${pendingSelection.model}`
869
+ : `${defaults.provider}/${defaults.model}`
870
+ const effort = resolveEffectiveSelection(
871
+ active?.selection.picked ?? pendingSelection,
872
+ session?.requestHeader()?.config,
873
+ defaults,
874
+ ).reasoningEffort
875
+ return createElement(App, {
876
+ key: session?.id ?? 'pending',
877
+ store,
878
+ approval,
879
+ questions,
880
+ commands,
881
+ skills,
882
+ model,
883
+ effort,
884
+ cwd: basename(sessionCwd),
885
+ workspaceRoot: sessionCwd,
886
+ branch: gitBranch(sessionCwd),
887
+ sessionId: session === undefined ? '' : session.id.slice(-8),
888
+ resumed: active?.resumed ?? false,
889
+ mode: active?.mode ?? '',
890
+ dispatch,
891
+ steer,
892
+ interrupt,
893
+ quit,
894
+ loadModels: () => loadModelDirectory(ctx),
895
+ loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
896
+ cyclePermission,
897
+ selectModel,
898
+ exportTranscript,
899
+ renameTitle,
900
+ loadPresets: () => presets.list(),
901
+ switchMode: switchModeAction,
902
+ createSession,
903
+ loadSessions,
904
+ loadSessionTranscript,
905
+ switchSession,
906
+ cancelSessionSwitch,
907
+ loadPlugins: () => listPluginRows(ctx),
908
+ statusline: statuslineItems,
909
+ saveStatusline,
910
+ saveTheme,
911
+ history: inputHistory,
912
+ recordHistory,
913
+ cancelQueued,
914
+ onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
915
+ })
916
+ }
917
+
918
+ const renderCurrent = (): void => {
919
+ mountRef.current?.rerender(appElement())
920
+ }
921
+
922
+ mountRef.current = io.mount(appElement())
923
+
924
+ // A corrupt statusline config must not vanish silently: surface it once
925
+ // the notice channel is live, after the first frame settles.
926
+ if (statuslineWarning !== undefined) {
927
+ setTimeout(() => {
928
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
929
+ }, 50)
930
+ }
931
+ // Same one-shot surface for a corrupt theme file (dark fallback stays live).
932
+ if (themeWarning !== undefined) {
933
+ setTimeout(() => {
934
+ bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
935
+ }, 50)
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Mount the interactive terminal driver.
941
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
942
+ * @param config - validated startup config resolved from the tuiStartup provider.
943
+ */
944
+ export function apply(ctx: Context, config: Config): void {
945
+ // The CLI validated --theme at parse time; the loose config schema falls
946
+ // back to dark for anything unexpected.
947
+ const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
948
+ const startup: TuiStartup =
949
+ config.startup.kind === 'resume' && config.startup.sessionId !== undefined
950
+ ? { kind: 'resume', sessionId: config.startup.sessionId, ...(theme === undefined ? {} : { theme }) }
951
+ : config.startup.kind === 'latest'
952
+ ? { kind: 'latest', ...(theme === undefined ? {} : { theme }) }
953
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
954
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
955
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...(theme === undefined ? {} : { theme }) }
956
+ // Read through the global service store, not the property proxy: appExit is
957
+ // an optional host value, never an injected dependency.
958
+ const exit = ctx.get('appExit')
959
+ if (exit === undefined) {
960
+ throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
961
+ }
962
+ const io: TuiIo = { mount: internals.mount, exit }
963
+ void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
964
+ }