dsh-code 1.0.1 → 1.0.3

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 (44) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +21 -13
  3. package/lib/index.mjs +1902 -1092
  4. package/lib/types/app.d.ts +4 -13
  5. package/lib/types/attachments.d.ts +1 -1
  6. package/lib/types/editor-keys.d.ts +105 -0
  7. package/lib/types/git-workflow.d.ts +6 -2
  8. package/lib/types/keyboard.d.ts +31 -0
  9. package/lib/types/mentions.d.ts +2 -0
  10. package/lib/types/model-capabilities.d.ts +82 -0
  11. package/lib/types/provider-settings.d.ts +7 -0
  12. package/lib/types/render/animations.d.ts +27 -11
  13. package/lib/types/render/editor.d.ts +32 -7
  14. package/lib/types/render/lines.d.ts +26 -1
  15. package/lib/types/render/markdown.d.ts +1 -1
  16. package/lib/types/render/projection.d.ts +15 -1
  17. package/lib/types/render/text.d.ts +15 -9
  18. package/lib/types/render/width.d.ts +29 -0
  19. package/lib/types/session-directory.d.ts +27 -0
  20. package/lib/types/settings-file.d.ts +33 -0
  21. package/lib/types/store.d.ts +10 -0
  22. package/lib/types/subagents.d.ts +13 -3
  23. package/package.json +159 -159
  24. package/src/app.ts +920 -764
  25. package/src/attachments.ts +7 -0
  26. package/src/editor-keys.ts +371 -0
  27. package/src/git-workflow.ts +10 -6
  28. package/src/index.ts +1637 -1523
  29. package/src/internals.ts +26 -9
  30. package/src/keyboard.ts +131 -7
  31. package/src/mentions.ts +6 -1
  32. package/src/model-capabilities.ts +318 -0
  33. package/src/provider-settings.ts +16 -0
  34. package/src/render/animations.ts +64 -17
  35. package/src/render/editor.ts +125 -25
  36. package/src/render/lines.ts +403 -342
  37. package/src/render/markdown.ts +4 -7
  38. package/src/render/projection.ts +63 -40
  39. package/src/render/text.ts +152 -150
  40. package/src/render/width.ts +189 -0
  41. package/src/session-directory.ts +56 -0
  42. package/src/settings-file.ts +56 -0
  43. package/src/store.ts +26 -7
  44. package/src/subagents.ts +39 -6
package/src/index.ts CHANGED
@@ -1,1523 +1,1637 @@
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, rm, stat, 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 type {} from '@deepseek-ai/dsh-attachment'
24
- import { createUserMessage, MessageId, type ContentBlock, type ImageBlock } from '@deepseek-ai/dsh-llm'
25
- import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
26
- import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
27
- import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
28
- // Type-only: carries the ctx.sessionTitle service merge for /title.
29
- import type {} from '@deepseek-ai/dsh-session-title'
30
- // Empty type imports carry the loader Context merge for the settlement await
31
- // and the cmdline Context merge for the appExit host value.
32
- import type {} from '@deepseek-ai/cordis-plugin-loader'
33
- import type {} from '@deepseek-ai/dsh-cmdline'
34
- import { App, type NoticeTone } from './app.ts'
35
- import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
36
- import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
37
- import { internals, type TuiMount } from './internals.ts'
38
- import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
39
- import {
40
- loadProviderSettings,
41
- removeProviderSettings,
42
- saveProviderCredential,
43
- saveProviderConfiguration,
44
- subscribeProviderSettings,
45
- unsetProviderCredential,
46
- } from './provider-settings.ts'
47
- import { createMentions, type MentionsApi } from './mentions.ts'
48
- import { mountQuestionProvider, type QuestionStore } from './questions.ts'
49
- import { createTranscriptStore, type TranscriptStore } from './store.ts'
50
- import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
51
- import { parseStatuslineItems } from './render/status.ts'
52
- import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
53
- import { watchSkills, type SkillsView } from './skills.ts'
54
- import { toolArgumentsPreview } from './render/tool-preview.ts'
55
- import { buildExportMarkdown } from './render/export.ts'
56
- import { inspectImagePaths, saveImagePaths } from './attachments.ts'
57
- import { copyText, latestAssistantText } from './editor.ts'
58
- import {
59
- beginProviderAuthorization,
60
- cancelProviderAuthorization,
61
- loadProviderAuthorizations,
62
- logoutProviderAuthorization,
63
- openAuthorizationUrl,
64
- subscribeProviderAuthorizations,
65
- } from './authorization.ts'
66
- import { selectForkSeed } from './fork.ts'
67
- import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
68
- import type { TuiStartup } from './startup.ts'
69
- import { SessionSwitchQueue } from './session-switch.ts'
70
- import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
71
- import {
72
- applyPendingPermission,
73
- cyclePermission as cyclePermissionPreset,
74
- effectivePermission,
75
- listPermissionRows,
76
- permissionPresetsFrom,
77
- selectPermission,
78
- } from './permissions.ts'
79
- import { listPluginRows } from './plugin-inventory.ts'
80
- import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
81
- import {
82
- collectDeletionSubtree,
83
- isSubagentSession,
84
- matchSessionId,
85
- mergeSessionTitles,
86
- newestRootForCwd,
87
- projectSessionRows,
88
- SESSION_ARTIFACT_NAMES,
89
- sessionArtifactDirectory,
90
- type SessionDirectoryOptions,
91
- type SessionQueryService,
92
- type SessionRow,
93
- } from './session-directory.ts'
94
-
95
- /** Stable Cordis plugin name. */
96
- export const name = 'tui-runner'
97
-
98
- /** Core services required before the interactive session can start. */
99
- export const inject = ['agentDefaultModel', 'agents', 'sessions']
100
-
101
- /** Plugin config: the startup resolved from this app's injected provider service. */
102
- export interface Config {
103
- /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
104
- startup: { kind: string; sessionId?: string; mode?: string; theme?: string; prompt?: string; images?: string[] }
105
- }
106
-
107
- export const Config: z<Config> = z.object({
108
- startup: z.object({
109
- kind: z.string().required(),
110
- sessionId: z.string(),
111
- mode: z.string(),
112
- theme: z.string(),
113
- prompt: z.string(),
114
- images: z.array(z.string()),
115
- }),
116
- })
117
-
118
- /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
119
- interface TuiIo {
120
- mount: typeof internals.mount
121
- exit(code: number): void
122
- }
123
-
124
- /** Report an unexpected direct-driver failure and request a failing exit. */
125
- function fail(io: TuiIo, error: unknown): void {
126
- internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
127
- io.exit(1)
128
- }
129
-
130
- /**
131
- * Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
132
- * started through run_in_background are fenced by their owner, so the CURRENT
133
- * agent is the caller. A missing registry is a harmless absence (the base
134
- * composition may not mount one) and collapses to the empty panel state
135
- * the documented degradation for harmless probes, not an error.
136
- * @param ctx - context carrying the optional `jobs` registry.
137
- * @param caller - the active agent (undefined sees only unowned jobs).
138
- * @returns job rows in registration order; never throws.
139
- */
140
- function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./kernel-panels.ts').JobRow[] {
141
- const jobs = ctx.get('jobs')
142
- if (jobs === undefined) return []
143
- try {
144
- return jobs.list(caller).map((job: JobSnapshot) => ({
145
- id: job.id,
146
- kind: job.kind,
147
- label: job.label,
148
- status: job.status,
149
- detail: job.detail,
150
- startedAt: job.startedAt,
151
- finishedAt: job.finishedAt,
152
- }))
153
- } catch {
154
- return []
155
- }
156
- }
157
-
158
- /**
159
- * Resolve the working directory's git branch for the status line.
160
- * @param cwd - the session's working directory.
161
- * @returns the branch name, or '' outside a repository or on a detached HEAD.
162
- */
163
- function gitBranch(cwd: string): string {
164
- try {
165
- const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
166
- return ref?.[1] ?? ''
167
- } catch {
168
- // Only the single HEAD read is attempted, so the sole reachable failure is
169
- // a missing repository (or unreadable HEAD file): the branch group drops out.
170
- return ''
171
- }
172
- }
173
-
174
- /** The session identity this invocation will run, plus whether it is resumed. */
175
- interface Target {
176
- sessionId: string
177
- resume: boolean
178
- mode?: string
179
- cwd?: string
180
- seed?: readonly SessionEvent[]
181
- parentSession?: SessionId
182
- seedLength?: number
183
- }
184
-
185
- /**
186
- * Reduce a session id to a filename-safe /export default-name suffix. Session
187
- * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
188
- * user text: path separators must never leak into the default export filename
189
- * (which would escape the session cwd).
190
- * @param id - the session id.
191
- * @returns at most the last 8 filename-safe characters.
192
- */
193
- export function exportSessionIdSuffix(id: string): string {
194
- return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
195
- }
196
-
197
- /** One ordered step of the terminal quit cleanup. */
198
- export interface QuitCleanupStep {
199
- /** Step label used in diagnostics and tests. */
200
- readonly name: string
201
- /** The step's async work; a rejection is contained by the sequence. */
202
- readonly run: () => Promise<void>
203
- }
204
-
205
- /**
206
- * Run the ordered quit cleanup, then request exit. Every step rejection is
207
- * contained (reported through `onError`) so a failed flush or dispose never
208
- * skips the remaining cleanup; the exit request is always reached exactly
209
- * once.
210
- * @param steps - the cleanup steps in dependency order (settle the visible
211
- * session, await the final in-flight composition, await durable recall).
212
- * @param exit - the terminal exit request (code 0).
213
- * @param onError - optional failure sink; called once per failing step and
214
- * itself contained, so a throwing sink cannot abort the sequence.
215
- * @returns the names of the steps that started, in order (for tests).
216
- */
217
- export async function runQuitSequence(
218
- steps: readonly QuitCleanupStep[],
219
- exit: (code: number) => void,
220
- onError?: (name: string, error: unknown) => void,
221
- ): Promise<readonly string[]> {
222
- const started: string[] = []
223
- for (const step of steps) {
224
- started.push(step.name)
225
- try {
226
- await step.run()
227
- } catch (error) {
228
- try {
229
- onError?.(step.name, error)
230
- } catch {
231
- // The failure sink must never abort the cleanup sequence.
232
- }
233
- }
234
- }
235
- try {
236
- exit(0)
237
- } catch {
238
- // The exit request itself must not become an unhandled rejection.
239
- }
240
- return started
241
- }
242
-
243
- /**
244
- * Resolve the invocation's target session against the persisted headers.
245
- * @param startup - the parsed startup flags.
246
- * @param persistence - the persistence service; required for resume/latest.
247
- * @param cwd - the working directory `--continue` filters by.
248
- * @returns the target identity.
249
- * @throws with a user-facing message when the flags name nothing resolvable.
250
- */
251
- export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
252
- if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
253
- if (startup.kind === 'named') {
254
- // The id must not exist yet: reject before any Agent composition when the
255
- // backend can tell us (a live collision is still caught by the session
256
- // store at create time).
257
- if (persistence !== undefined) {
258
- const headers: readonly SessionHeader[] = await persistence.list()
259
- if (headers.some(header => header.id === startup.sessionId)) {
260
- throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
261
- }
262
- }
263
- return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
264
- }
265
- if (persistence === undefined) {
266
- throw new Error('cannot resolve the requested session: session persistence is not configured')
267
- }
268
- const headers: readonly SessionHeader[] = await persistence.list()
269
- if (startup.kind === 'resume') {
270
- const matched = matchSessionId(headers, startup.sessionId)
271
- // Subagent conversations are read-only everywhere else; the CLI must not
272
- // be a back door into appending root turns to a child's durable log.
273
- if (isSubagentSession(matched)) {
274
- throw new Error('subagent conversations are read-only; resume a root session')
275
- }
276
- return { sessionId: matched.id, resume: true }
277
- }
278
- // --continue: the newest persisted ROOT session whose header pins this cwd.
279
- const newest = newestRootForCwd(headers, cwd)
280
- if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
281
- return { sessionId: newest.id, resume: true }
282
- }
283
-
284
- /**
285
- * Resolve a bounded command preview for one pending approval: the request
286
- * contract carries no arguments, so the bar self-serves from the transcript
287
- * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
288
- * @param events - the transcript entries to search.
289
- * @param callId - the tool call the question is about, when the asker had one.
290
- * @param toolName - the tool the question is about.
291
- * @returns a bounded preview line, '' when nothing useful resolves.
292
- */
293
- function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
294
- if (callId === undefined) return ''
295
- const entry = events.find(candidate =>
296
- candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
297
- if (entry === undefined) return ''
298
- const args = (entry as { arguments?: string }).arguments ?? ''
299
- return toolArgumentsPreview(args, toolName)
300
- }
301
-
302
- /** The runner's connection between the React app and the process side. */
303
- interface AppBridge {
304
- /** Post one local notice line (feedback the transcript does not carry). */
305
- notify(text: string, tone?: NoticeTone): void
306
- }
307
-
308
- /**
309
- * Run the interactive terminal session: resolve the target session, create or
310
- * resume one Agent, mount the app, and keep the process alive until the user
311
- * quits.
312
- * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
313
- * @param startup - the parsed invocation flags.
314
- * @param io - process-facing effects.
315
- */
316
- async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
317
- // Loader siblings mount concurrently. Await the complete application before
318
- // creating an Agent so its scoped tools and adapters are not half-composed.
319
- await ctx.get('loader')?.await()
320
- const agents = ctx.get('agents')
321
- const defaultModel = ctx.get('agentDefaultModel')
322
- const sessions = ctx.get('sessions')
323
- const persistence = ctx.get('sessionPersistence')
324
- const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
325
- // Early process shutdown can dispose the tree while settlement is pending.
326
- if (agents === undefined || defaultModel === undefined || sessions === undefined) return
327
-
328
- const cwd = process.cwd()
329
- // Live deployment default (web selectModel parity): read on every use, not
330
- // snapshotted at launch, so a /model pick this process saves becomes the
331
- // default for sessions composed afterwards without a restart.
332
- const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
333
- const presets = agentPresetsFrom(ctx)
334
- if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
335
- const permissionPresets = permissionPresetsFrom(ctx)
336
-
337
- // A bare fresh launch stays transient: no Agent or session is composed, and
338
- // nothing is persisted, until the user's first real input. Explicit flags
339
- // (--resume/--continue/--session/--mode) keep the eager create/resume path.
340
- const lazy = startup.kind === 'fresh' && startup.mode === undefined
341
-
342
- interface ActiveSession {
343
- handle: AgentHandle
344
- agent: Agent
345
- session: Session
346
- store: ReturnType<typeof createTranscriptStore>
347
- mentions: MentionsApi
348
- mode: string
349
- selection: { picked?: ModelSelection }
350
- resumed: boolean
351
- }
352
-
353
- /** Prepare a complete next session before disturbing the currently visible one. */
354
- const prepare = async (next: Target): Promise<ActiveSession> => {
355
- const nextCwd = next.cwd ?? cwd
356
- // A bare launch can pick a model before any session exists: the process
357
- // keeps that explicit choice and every prepared session starts from it
358
- // (the documented precedence: explicit pick > session header > default).
359
- const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
360
- ? {}
361
- : { picked: pendingSelection }
362
- let mode = next.resume ? next.mode : next.mode ?? pendingMode
363
- if (!next.resume) mode = (await presets.resolve(mode)).id
364
- const setup = async (agentCtx: Context): Promise<void> => {
365
- const sessionPreset = next.resume
366
- ? resolvePreset(agentCtx.agent!.session)
367
- : mode
368
- const mounted = await presets.mount(agentCtx, sessionPreset)
369
- mode = mounted.id
370
- const selection: ModelSelectionRef = {
371
- get current(): ModelSelection | undefined {
372
- return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
373
- },
374
- set current(value: ModelSelection | undefined) { selectionState.picked = value },
375
- assembled: undefined,
376
- }
377
- installModelSelection(agentCtx, selection)
378
- }
379
- // AgentOptions seed the loop's fallback route; effort rides the selection
380
- // ref (installModelSelection), so only the provider/model pair is seeded.
381
- const seedOptions = pendingSelection === undefined
382
- ? { provider: currentDefaults().provider, model: currentDefaults().model }
383
- : { provider: pendingSelection.provider, model: pendingSelection.model }
384
- const handle = next.resume
385
- ? await agents.resume({
386
- resumeSessionId: SessionId(next.sessionId),
387
- agentOptions: seedOptions,
388
- // Quit aborts an in-flight composition so the exit wait never hangs
389
- // on a prepare that cannot settle; upstream rolls the creation back.
390
- signal: quitAbort.signal,
391
- setup,
392
- })
393
- : await agents.create({
394
- sessionId: SessionId(next.sessionId),
395
- meta: {
396
- cwd: nextCwd,
397
- agentPreset: mode,
398
- ...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
399
- ...(next.seedLength === undefined ? {} : { seedLength: next.seedLength }),
400
- },
401
- ...(next.seed === undefined ? {} : { seed: next.seed }),
402
- agentOptions: seedOptions,
403
- signal: quitAbort.signal,
404
- setup,
405
- })
406
- const session = handle.agent.session
407
- if (!next.resume && permissionPresets !== undefined) {
408
- applyPendingPermission(permissionPresets, session, pendingPermission)
409
- }
410
- return {
411
- handle,
412
- agent: handle.agent,
413
- session,
414
- store: createTranscriptStore(session.events),
415
- mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
416
- mode: mode ?? 'standard',
417
- selection: selectionState,
418
- resumed: next.resume,
419
- }
420
- }
421
-
422
- let active: ActiveSession | undefined
423
- let agent: Agent | undefined
424
- let session: Session | undefined
425
- let store: TranscriptStore = createTranscriptStore()
426
- // Live subagent activity (child sessions of the current root): one bounded
427
- // row per child, folded from the same event bus the transcript feeds on.
428
- const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
429
- // Pre-session @file completion runs the official search over the launch
430
- // cwd (model- and session-independent); the prepare/activate paths replace
431
- // this with the agent-scoped instance once a session exists.
432
- let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
433
- /** Explicit model pick made before any session exists (a bare launch). */
434
- let pendingSelection: ModelSelection | undefined
435
- /** Agent preset selected before the first session exists. */
436
- let pendingMode: string | undefined
437
- /** Ordered pre-session preset resolutions; first composition awaits them. */
438
- let pendingModeWork: Promise<void> = Promise.resolve()
439
- /** Permission preset selected before the first session exists. */
440
- let pendingPermission: string | undefined
441
- /**
442
- * Monotonic session epoch: bumped on every successful activation, on every
443
- * first-session creation, and on quit. Async callbacks (mention prepares,
444
- * command executions) capture it at call time and drop their result when it
445
- * changed, so a stale callback can never deliver to an agent that is no
446
- * longer on screen.
447
- */
448
- let epoch = 0
449
- /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
450
- const quitAbort = new AbortController()
451
- /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
452
- const pendingControllers = new Set<AbortController>()
453
- const abortPendingControllers = (): void => {
454
- for (const controller of [...pendingControllers]) {
455
- pendingControllers.delete(controller)
456
- controller.abort()
457
- }
458
- }
459
- /** The in-flight session-composition turn (create/resume/activate), if any. */
460
- let composing: Promise<void> | undefined
461
- /**
462
- * Run one session composition exclusively: concurrent compositions wait
463
- * their turn, so a bare-launch first-session creation and a /resume
464
- * activation can never compose agents in parallel (the loser would leak its
465
- * agent or mis-deliver). Errors propagate to the caller; the shared slot
466
- * always continues.
467
- */
468
- const compose = (work: () => Promise<void>): Promise<void> => {
469
- const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
470
- composing = turn.catch(() => {})
471
- return turn
472
- }
473
-
474
- if (!lazy) {
475
- const target = await resolveTarget(startup, persistence, cwd)
476
- const prepared = await prepare(target)
477
- active = prepared
478
- agent = prepared.agent
479
- session = prepared.session
480
- store = prepared.store
481
- mentions = prepared.mentions
482
- }
483
-
484
- // Seed the transcript from the full session log: constructor seeds never
485
- // fire on `session/event`, so a resumed session paints its history once
486
- // before the first render. The handler reads the current session/store, so
487
- // the deferred first session of a bare launch is covered by the same feed.
488
- const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
489
- if (session === undefined) return
490
- if (subject.id === session.id) {
491
- store.apply(event)
492
- return
493
- }
494
- // Child sessions (subagent conversations this root spawned) fold into
495
- // the bounded live-activity feed, never the transcript: the root stays
496
- // the only durable transcript truth while a running subagent remains
497
- // visible. Lineage comes from the child header, same field the session
498
- // directory uses to tag `↳` rows.
499
- if (subject.header.parentSession === session.id && subject.header.origin === 'subagent') subagents.apply(subject.id, event)
500
- })
501
-
502
- const commands: CommandsView = watchCommands(ctx)
503
- if (agent !== undefined) commands.setAgent(agent)
504
-
505
- const skills: SkillsView = watchSkills(ctx)
506
- if (agent !== undefined) skills.setAgent(agent)
507
-
508
- // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
509
- // claimed, every other ask falls through to the fail-closed waterfall. The
510
- // owner predicate is empty until the first session exists.
511
- const approval: ApprovalStore = mountApprovalAnswerer(
512
- ctx,
513
- candidate => agent !== undefined && candidate.id === agent.id,
514
- request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
515
- )
516
-
517
- // Subagent model routing. The kernel seeds child agents from the parent's
518
- // CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
519
- // /model switch never touches — delegated work would keep running on the
520
- // launch-time route. This plugin-level listener mirrors installModelSelection
521
- // for subagent-origin requests (scope filtering delivers the agent subject
522
- // inside the payload): the explicit /subagent override wins, else the root's
523
- // effective selection (explicit pick > session header > deployment default).
524
- // Effort rides the selection exactly like the kernel listener applies it.
525
- let subagentOverride: ModelSelection | undefined
526
- ctx.on('agent/request', (payload, next) => {
527
- const subject = payload.agent
528
- const header = subject.session.header
529
- if (header.parentSession === undefined && header.origin !== 'subagent') return next()
530
- const picked = subagentOverride
531
- ?? resolveEffectiveSelection(
532
- active?.selection.picked ?? pendingSelection,
533
- subject.session.requestHeader()?.config,
534
- currentDefaults(),
535
- )
536
- return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
537
- })
538
-
539
- // ask_user_question provider: the single UI provider on the shared service,
540
- // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
541
- // through this same pipe.
542
- const questions: QuestionStore = mountQuestionProvider(ctx)
543
-
544
- // The bridge the React app registers on mount: local notices from the
545
- // process side (unknown commands, switch confirmations, cancels).
546
- const bridge: AppBridge = { notify: () => {} }
547
-
548
- // /statusline persistence: one user-level JSON file under the DSH home.
549
- // Missing file means defaults; a corrupt file degrades to defaults with a
550
- // surfaced warning (the customization is user-authored, never silent).
551
- const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
552
- let statuslineWarning: string | undefined
553
- let statuslineItems: readonly string[] = []
554
- try {
555
- statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
556
- } catch (error) {
557
- statuslineItems = parseStatuslineItems(undefined)
558
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
559
- statuslineWarning = error instanceof Error ? error.message : String(error)
560
- }
561
- }
562
- const saveStatusline = (items: readonly string[]): void => {
563
- statuslineItems = [...items]
564
- // The config directory may not exist on a first save; create it before
565
- // the write so a fresh install persists customizations.
566
- void mkdir(dirname(statuslinePath), { recursive: true })
567
- .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
568
- .catch((writeError: unknown) => {
569
- bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
570
- })
571
- }
572
-
573
- // /theme persistence: one user-level JSON file under the DSH home, mirroring
574
- // the statusline file. A missing file means the dark default; a corrupt file
575
- // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
576
- // auto detection > dark (auto detection itself is a later enhancement and
577
- // currently falls back to dark inside theme.ts).
578
- const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
579
- let themeWarning: string | undefined
580
- if (startup.theme === undefined) {
581
- try {
582
- setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
583
- } catch (error) {
584
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
585
- themeWarning = error instanceof Error ? error.message : String(error)
586
- }
587
- }
588
- } else {
589
- setTheme(startup.theme)
590
- }
591
- const saveTheme = (name: ThemeName): void => {
592
- setTheme(name)
593
- void mkdir(dirname(themePath), { recursive: true })
594
- .then(() => writeFileAsync(themePath, JSON.stringify({ theme: name }, null, 2) + '\n', 'utf8'))
595
- .catch((writeError: unknown) => {
596
- bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
597
- })
598
- }
599
-
600
- // Global input recall (Codex composer-history contract): one JSONL file
601
- // under the DSH home. A missing file means an empty history; unreadable or
602
- // corrupt content degrades to the valid lines it could parse, silently —
603
- // recall is a convenience surface, never a gate.
604
- const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
605
- let inputHistory: readonly string[] = []
606
- try {
607
- inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
608
- } catch {
609
- inputHistory = []
610
- }
611
- /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
612
- let historyWriteChain: Promise<void> = Promise.resolve()
613
- const recordHistory = (text: string): void => {
614
- if (text === '') return
615
- inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
616
- // Write the whole current list, serialized per submission: the file is
617
- // never read back on the submit path, so rapid same-process submissions
618
- // cannot lose entries to a read-modify-write race.
619
- historyWriteChain = historyWriteChain
620
- .then(() => mkdir(dirname(historyPath), { recursive: true }))
621
- .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
622
- .catch((writeError: unknown) => {
623
- bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
624
- })
625
- }
626
-
627
- /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
628
- const cancelQueued = (messageId: string): void => {
629
- if (agent === undefined) return
630
- try {
631
- if (agent.inbox.remove(MessageId(messageId))) {
632
- bridge.notify('queued message cancelled')
633
- }
634
- } catch (error: unknown) {
635
- bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
636
- }
637
- }
638
-
639
- // The mount handle lives in a box: quit closes over it, while the mount
640
- // itself is created after quit (the App element needs quit as a prop).
641
- const mountRef: { current?: TuiMount } = {}
642
- let quitting = false
643
- const quit = (): void => {
644
- if (quitting) return
645
- quitting = true
646
- switchQueue.cancel()
647
- // Stale prepares/commands die with the session they were for. Aborting
648
- // the composition signal lets a never-settling prepare reject, so the
649
- // exit wait below cannot hang (upstream rolls the creation back).
650
- abortPendingControllers()
651
- quitAbort.abort()
652
- epoch += 1
653
- off()
654
- mountRef.current?.unmount()
655
- const currentSession = session
656
- const currentActive = active
657
- const report = (name: string, error: unknown): void => {
658
- internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
659
- }
660
- // One ordered cleanup: settle the visible session (if any — a bare launch
661
- // that never composed one resolves immediately), then wait for the final
662
- // in-flight composition (its work swallows errors and the quitting guard
663
- // disposes any half-prepared agent), then flush the durable recall, then
664
- // request exit. `composing` and `historyWriteChain` are read at step run
665
- // time, so a turn that was still being queued when quit ran is included.
666
- // A failing step must never skip the remaining cleanup.
667
- const steps: QuitCleanupStep[] = [
668
- ...(currentSession === undefined || currentActive === undefined
669
- ? []
670
- : [
671
- { name: 'flush', run: async () => { await sessions.flush(currentSession) } },
672
- { name: 'dispose', run: () => currentActive.handle.dispose() },
673
- ]),
674
- { name: 'composing', run: () => composing ?? Promise.resolve() },
675
- { name: 'history', run: () => historyWriteChain },
676
- ]
677
- void runQuitSequence(steps, io.exit, report)
678
- }
679
-
680
- /** Run one slash line through the command registry (closed namespace). */
681
- const runSlash = (line: string): void => {
682
- const currentAgent = agent
683
- if (currentAgent === undefined) return
684
- if (line.startsWith('/resume ')) {
685
- requestResume(line.slice(8).trim())
686
- return
687
- }
688
- const registry = ctx.get('commands')
689
- if (registry === undefined) {
690
- bridge.notify('no command registry is mounted in this composition', 'error')
691
- return
692
- }
693
- const controller = new AbortController()
694
- const atEpoch = epoch
695
- pendingControllers.add(controller)
696
- const finish = (): void => {
697
- pendingControllers.delete(controller)
698
- }
699
- // rc.8 registry.execute gained an `images` admission parameter; the TUI
700
- // composer never attaches images to a slash line, so every invocation is
701
- // the empty batch (commands declaring input.images still run image-free).
702
- void Promise.resolve().then(() => registry.execute(currentAgent, line, [], controller.signal)).then((execution) => {
703
- finish()
704
- // A switch/quit landed while the command ran: its fall-through must not
705
- // reach an agent that is no longer on screen.
706
- if (epoch !== atEpoch || agent !== currentAgent) return
707
- if (execution === undefined) {
708
- // No command owns this line: send it verbatim so a user-invocable
709
- // skill gesture (`/skill-name`) reaches the host's tool-skill
710
- // pre-step injection the web composer's same fall-through.
711
- try {
712
- currentAgent.followup(createUserMessage({
713
- content: [{ type: 'text', text: line }],
714
- source: { kind: 'user' },
715
- }))
716
- } catch (error: unknown) {
717
- bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
718
- }
719
- }
720
- }, (error: unknown) => {
721
- finish()
722
- if (epoch !== atEpoch || agent !== currentAgent) return
723
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
724
- })
725
- }
726
-
727
- /** Deliver one trimmed line to the live session, expanding mentions first. */
728
- const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
729
- const currentAgent = agent!
730
- const currentMentions = mentions!
731
- // The command registry is a closed namespace: slash lines run out of
732
- // band and never reach the model through this path (steering keeps the
733
- // registry out of the inbox, so slash lines steer as literal text).
734
- if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
735
- runSlash(line)
736
- return
737
- }
738
- let parsed: ReturnType<MentionsApi['parse']>
739
- try {
740
- parsed = currentMentions.parse(line)
741
- } catch (error: unknown) {
742
- bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
743
- return
744
- }
745
- const atEpoch = epoch
746
- const deliver = (readable: string, context?: UserMessage): void => {
747
- // A switch/quit landed while the snapshot was being prepared: never
748
- // deliver to an agent that is no longer on screen.
749
- if (epoch !== atEpoch || agent !== currentAgent) return
750
- // Session snapshots ride the inbox as model-facing context ahead of
751
- // the readable message (upstream README wiring: inject before the
752
- // followup/steer that wakes the driver).
753
- try {
754
- if (context !== undefined) currentAgent.inject(context)
755
- const content: ContentBlock[] = [
756
- ...(readable === '' ? [] : [{ type: 'text' as const, text: readable }]),
757
- ...images,
758
- ]
759
- const message = createUserMessage({
760
- content,
761
- source: { kind: 'user' },
762
- })
763
- if (mode === 'steer') {
764
- // The queued message is visible as a pending transcript row (the
765
- // web queue-mirror contract); no notice noise on the happy path.
766
- currentAgent.steer(message)
767
- } else {
768
- currentAgent.followup(message)
769
- }
770
- } catch (error: unknown) {
771
- bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
772
- }
773
- }
774
- if (parsed.references.length === 0) {
775
- deliver(parsed.text)
776
- return
777
- }
778
- const controller = new AbortController()
779
- pendingControllers.add(controller)
780
- void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
781
- pendingControllers.delete(controller)
782
- deliver(prepared.text, prepared.additionalContext)
783
- }, (error: unknown) => {
784
- pendingControllers.delete(controller)
785
- if (controller.signal.aborted || epoch !== atEpoch) return
786
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
787
- })
788
- }
789
-
790
- // Deferred first-session creation for a bare launch: the session is composed
791
- // only when the user submits real input (or /new), and every line that
792
- // arrives during creation is delivered in order afterwards. A creation
793
- // failure reports and clears the queue, leaving the transient state ready
794
- // for the next attempt.
795
- const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer'; images: readonly ImageBlock[] }> = []
796
- // A creation is queued/running: further submissions must not mint more
797
- // fresh sessions (their lines queue into pendingInputs instead).
798
- let creating = false
799
- const ensureSession = (mode?: string): void => {
800
- if (creating) return
801
- creating = true
802
- void compose(async () => {
803
- try {
804
- // A direct `/mode <preset>` resolves asynchronously. Preserve submit
805
- // order so the first composition cannot race ahead with the old mode.
806
- await pendingModeWork
807
- // Another composition (e.g. a /resume activated while this creation
808
- // waited its turn) may have published a session already: deliver the
809
- // queued lines there instead of minting a competing fresh session
810
- // (which would orphan the live one without a dispose).
811
- if (session !== undefined) {
812
- const queued = pendingInputs.splice(0)
813
- for (const item of queued) deliverLine(item.text, item.mode, item.images)
814
- return
815
- }
816
- const next = await prepare({
817
- sessionId: `session-${randomUUID()}`,
818
- resume: false,
819
- ...(mode === undefined ? {} : { mode }),
820
- })
821
- if (quitting) {
822
- void next.handle.dispose().catch(() => {})
823
- return
824
- }
825
- active = next
826
- agent = next.agent
827
- session = next.session
828
- store = next.store
829
- mentions = next.mentions
830
- subagents.reset()
831
- pendingMode = undefined
832
- pendingPermission = undefined
833
- commands.setAgent(agent)
834
- skills.setAgent(agent)
835
- // The App mounts with a placeholder key until the first input; the
836
- // key-change remount below must start from a clean screen or the ghost
837
- // static header stays visible above the new one (same source-backed
838
- // clear the session-switch path performs).
839
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
840
- renderCurrent()
841
- abortPendingControllers()
842
- epoch += 1
843
- const queued = pendingInputs.splice(0)
844
- for (const item of queued) deliverLine(item.text, item.mode, item.images)
845
- } finally {
846
- creating = false
847
- }
848
- }).catch((error: unknown) => {
849
- pendingInputs.length = 0
850
- bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
851
- })
852
- }
853
-
854
- /** Deliver one readable line to the agent, expanding session mentions first. */
855
- const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
856
- const line = text.trim()
857
- if (line === '' && images.length === 0) return
858
- if (images.length === 0 && line.startsWith('/mode ')) {
859
- void switchModeAction(line.slice(6).trim()).then(
860
- selected => bridge.notify(`mode → ${selected}`),
861
- error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
862
- )
863
- return
864
- }
865
- if (images.length === 0 && line.startsWith('/permission ')) {
866
- try {
867
- const selected = setPermissionAction(line.slice(12).trim())
868
- bridge.notify(`permission ${selected}`)
869
- } catch (error: unknown) {
870
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
871
- }
872
- return
873
- }
874
- if (session === undefined) {
875
- pendingInputs.push({ text: line, mode, images })
876
- ensureSession()
877
- return
878
- }
879
- deliverLine(line, mode, images)
880
- }
881
-
882
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
883
- const dispatch = (text: string, images: readonly ImageBlock[] = []): void => {
884
- send(text, 'followup', images)
885
- }
886
-
887
- /**
888
- * Submit steering: a running driver consumes the text at its next step
889
- * boundary (the inbox delivers between steps); an idle driver just starts
890
- * a turn, so this doubles as the busy-state submit path.
891
- */
892
- const steer = (text: string, images: readonly ImageBlock[] = []): void => {
893
- send(text, 'steer', images)
894
- }
895
-
896
- /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
897
- const interrupt = (): boolean => {
898
- if (agent === undefined || agent.status !== 'running') return false
899
- try {
900
- agent.cancel({ kind: 'user' })
901
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
902
- return true
903
- } catch (error: unknown) {
904
- bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
905
- return false
906
- }
907
- }
908
-
909
- /** Select one permission preset before the first session or on the active one. */
910
- const setPermissionAction = (id: string): string => {
911
- if (permissionPresets === undefined || permissionPresets.names.length === 0) {
912
- throw new Error('permission presets are not mounted in this composition')
913
- }
914
- if (id === '') throw new Error('usage: /permission <preset>')
915
- const selected = selectPermission(permissionPresets, session, id)
916
- if (session === undefined) {
917
- pendingPermission = selected
918
- renderCurrent()
919
- }
920
- return selected
921
- }
922
-
923
- /**
924
- * Cycle to the next permission preset (Shift+Tab). Before the first session,
925
- * the choice remains process-local and is materialized when Harness creates
926
- * that session; afterwards the canonical service writes durable events.
927
- */
928
- const cyclePermission = (): string => {
929
- if (permissionPresets === undefined || permissionPresets.names.length === 0) {
930
- bridge.notify('permission presets are not mounted in this composition', 'warning')
931
- return ''
932
- }
933
- try {
934
- const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
935
- if (session === undefined && next !== '') {
936
- pendingPermission = next
937
- renderCurrent()
938
- }
939
- return next
940
- } catch (error: unknown) {
941
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
942
- return ''
943
- }
944
- }
945
-
946
- /**
947
- * Apply one /model selection: takes effect from the next assembled step.
948
- * The optional reasoning effort must be one the row advertises (the picker
949
- * only offers those), so an unsupported value cannot reach the request
950
- * pipeline; an absent effort restores the model's own default.
951
- */
952
- const selectModel = (row: ModelRow, effortId?: string): string => {
953
- const selection = buildModelSelection(row, effortId)
954
- if (active === undefined) {
955
- // A bare launch has no session yet: keep the pick process-wide so the
956
- // first composed session starts from it.
957
- pendingSelection = selection
958
- } else {
959
- active.selection.picked = selection
960
- }
961
- // Global default (web selectModel parity): every pick is persisted as the
962
- // deployment default through the same agentDefaultModel service the web
963
- // host writes, so the choice survives restarts and other surfaces read
964
- // it. Save failures degrade to a notice — the in-session switch already
965
- // took effect and must not roll back (the web contract).
966
- void defaultModel.saveSelection(selection).catch((error: unknown) => {
967
- bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
968
- })
969
- // Advisory immediate validation (web selectModel parity): run the same
970
- // local resolveCallConfig check the request pipeline would, so a stale
971
- // directory an effort the adapter withdrew since /model loaded —
972
- // surfaces as a pick-time notice instead of failing the next assembled
973
- // step. Best-effort: an llm service without the resolver keeps the
974
- // existing request-boundary rejection. Called as a method (`this`-bound)
975
- // like resolveModelInfo in models.ts.
976
- const llm = ctx.get('llm')
977
- const resolveCallConfig = (llm as {
978
- resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
979
- } | undefined)?.resolveCallConfig
980
- if (llm !== undefined && typeof resolveCallConfig === 'function') {
981
- void Promise.resolve(resolveCallConfig.call(llm, {
982
- provider: selection.provider,
983
- model: selection.model,
984
- ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
985
- })).catch((error: unknown) => {
986
- bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
987
- })
988
- }
989
- return `${row.provider}/${row.model}`
990
- }
991
-
992
- /** The /subagent override label, '' when delegated agents follow the current model. */
993
- const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
994
-
995
- /** Apply one /subagent model pick; returns the override label. */
996
- const setSubagentModel = (row: ModelRow, effortId?: string): string => {
997
- subagentOverride = buildModelSelection(row, effortId)
998
- renderCurrent()
999
- return modelSelectionLabel(subagentOverride)
1000
- }
1001
-
1002
- /** Drop the /subagent override: delegated agents follow the current model again. */
1003
- const clearSubagentModel = (): void => {
1004
- subagentOverride = undefined
1005
- renderCurrent()
1006
- }
1007
-
1008
- /**
1009
- * Export the folded transcript to a markdown file (/export). The default
1010
- * target sits beside the session's cwd so the file lands in the user's
1011
- * workspace; an absolute or cwd-relative argument overrides it.
1012
- */
1013
- const exportTranscript = async (argument: string): Promise<void> => {
1014
- if (session === undefined) {
1015
- bridge.notify('no session yet submit a message to start', 'warning')
1016
- return
1017
- }
1018
- const wanted = argument.trim()
1019
- const sessionCwd = session.header.cwd ?? cwd
1020
- // The default name derives from the session id, which `--session` lets the
1021
- // user spell freely: reduce it to filename-safe characters first so the
1022
- // default target can never escape the session cwd.
1023
- const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
1024
- const target = wanted === ''
1025
- ? join(sessionCwd, defaultName)
1026
- : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
1027
- ? wanted
1028
- : join(sessionCwd, wanted)
1029
- const markdown = buildExportMarkdown(store.getView(), session.id)
1030
- try {
1031
- await writeFileAsync(target, `${markdown}\n`, 'utf8')
1032
- bridge.notify(`exported to ${target}`)
1033
- } catch (error: unknown) {
1034
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1035
- }
1036
- }
1037
-
1038
- /**
1039
- * Rename the session (/title): a user title pins the session and stops
1040
- * automatic generation (the service's own contract). The appended
1041
- * `session/title` event flows back through the store into the status line.
1042
- */
1043
- const renameTitle = (argument: string): string => {
1044
- const title = argument.trim()
1045
- if (title === '') return 'usage: /title <text>'
1046
- if (session === undefined) return 'no session yet — submit a message to start'
1047
- const service = ctx.get('sessionTitle')
1048
- if (service === undefined) return 'session titles are unavailable in this profile'
1049
- try {
1050
- service.rename(session, title)
1051
- return `title → ${title}`
1052
- } catch (error: unknown) {
1053
- return `rename failed: ${error instanceof Error ? error.message : String(error)}`
1054
- }
1055
- }
1056
-
1057
- const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
1058
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1059
- const records = await sessionQuery.listSessions(signal)
1060
- // Last-activity timestamps for sorting (codex UpdatedAt default): the
1061
- // JSONL artifact's mtime via locate()+stat the upstream api-proxy's own
1062
- // cold-probe pattern. O(1) per session; backends without a location (or
1063
- // vanished files) fall back to createdAt inside the projection.
1064
- const updated = new Map<string, number>()
1065
- for (const record of records) {
1066
- const location = persistence?.locate(record.header)
1067
- if (location === undefined) continue
1068
- try {
1069
- updated.set(record.header.id, (await stat(location.path)).mtimeMs)
1070
- } catch {
1071
- // Artifact gone or unreadable: the projection falls back to createdAt.
1072
- }
1073
- }
1074
- const projected = projectSessionRows(records, options, updated)
1075
- // Titles are the expensive fold. Fetch only the first bounded picker page;
1076
- // navigation/filter changes trigger a fresh, cancellable observation.
1077
- const page = projected.slice(0, 32)
1078
- if (page.length === 0) return projected
1079
- const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
1080
- return mergeSessionTitles(projected, observations)
1081
- }
1082
-
1083
- /**
1084
- * Delete one session subtree (/delete, codex semantics: subagent threads go
1085
- * with their root). The kernel persistence seam has NO deletion API by
1086
- * design logs accumulate "until removed externally" — so this is the
1087
- * controlled external removal: guards (live/current refusal, subtree
1088
- * collection, and the JSONL layout check `encodeSegment(id)/session.jsonl`)
1089
- * run before any filesystem touch, and only the backend-located artifacts
1090
- * are removed. Backends without a locatable artifact (SQLite) are refused.
1091
- * @param id - the root session id to delete.
1092
- * @returns the outcome line for the panel/notice.
1093
- */
1094
- const deleteSession = async (id: string): Promise<string> => {
1095
- if (sessionQuery === undefined) return 'session query is unavailable in this profile'
1096
- if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
1097
- const records = await sessionQuery.listSessions()
1098
- const target = records.find(record => record.header.id === id)
1099
- if (target === undefined) return `no persisted session matches "${id}"`
1100
- if (target.live) return 'cannot delete a live session — it is open in this or another process'
1101
- const doomed = collectDeletionSubtree(records, id)
1102
- const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
1103
- let removed = 0
1104
- for (const candidate of doomed) {
1105
- const record = byId.get(candidate)
1106
- if (record === undefined || record.live) continue
1107
- const location = persistence?.locate(record.header)
1108
- if (location === undefined) {
1109
- return `session backend exposes no deletable artifact for ${candidate.slice(-12)} (deletion is unsupported on this backend)`
1110
- }
1111
- const dir = sessionArtifactDirectory(location.path, candidate)
1112
- if (dir === undefined) {
1113
- return `refusing to delete: unexpected artifact layout at ${location.path}`
1114
- }
1115
- try {
1116
- for (const name of SESSION_ARTIFACT_NAMES) {
1117
- await rm(join(dir, name), { force: true })
1118
- }
1119
- // Remove the now-empty session directory; a non-empty one stays (an
1120
- // unexpected sibling file is never ours to delete).
1121
- await rm(dir, { force: true, recursive: false }).catch(() => {})
1122
- removed += 1
1123
- } catch (error: unknown) {
1124
- return `delete failed for ${candidate.slice(-12)}: ${error instanceof Error ? error.message : String(error)}`
1125
- }
1126
- }
1127
- return `deleted ${removed} session${removed === 1 ? '' : 's'}`
1128
- }
1129
-
1130
- const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
1131
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1132
- const snapshot = await sessionQuery.readSession(id, signal)
1133
- return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
1134
- }
1135
-
1136
- const switchModeAction = async (id: string): Promise<string> => {
1137
- if (id === '') throw new Error('usage: /mode <preset>')
1138
- const currentAgent = agent
1139
- if (currentAgent === undefined) {
1140
- const choice = pendingModeWork.then(async () => {
1141
- const preset = await selectPreset(presets, undefined, id)
1142
- // A resume may have won while this roster read was in flight; never
1143
- // leak the old pending choice into a later /new session.
1144
- if (agent === undefined) {
1145
- pendingMode = preset.id
1146
- renderCurrent()
1147
- }
1148
- return preset.id
1149
- })
1150
- pendingModeWork = choice.then(() => {}, () => {})
1151
- return choice
1152
- }
1153
-
1154
- const preset = await selectPreset(presets, currentAgent, id)
1155
- if (active === undefined) throw new Error('active Agent has no session state')
1156
- active.mode = preset.id
1157
- commands.setAgent(currentAgent)
1158
- skills.setAgent(currentAgent)
1159
- renderCurrent()
1160
- return preset.id
1161
- }
1162
-
1163
- interface PendingSwitch { readonly target: Target; readonly label: string }
1164
-
1165
- const activate = (nextTarget: Target): Promise<void> => {
1166
- if (quitting) return Promise.resolve()
1167
- // Serialized with every other composition (bare-launch creation, queued
1168
- // switches): at most one agent is composed at a time.
1169
- return compose(async () => {
1170
- const previous = active
1171
- const next = await prepare(nextTarget)
1172
- // Quit landed while the next session was being composed: dispose the
1173
- // half-ready agent and leave the current session untouched.
1174
- if (quitting) {
1175
- await next.handle.dispose().catch(() => {})
1176
- return
1177
- }
1178
- active = next
1179
- agent = next.agent
1180
- session = next.session
1181
- store = next.store
1182
- mentions = next.mentions
1183
- subagents.reset()
1184
- pendingMode = undefined
1185
- pendingPermission = undefined
1186
- commands.setAgent(agent)
1187
- skills.setAgent(agent)
1188
- try {
1189
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1190
- renderCurrent()
1191
- } catch (error: unknown) {
1192
- active = previous
1193
- agent = previous?.agent
1194
- session = previous?.session
1195
- store = previous === undefined ? createTranscriptStore() : previous.store
1196
- mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
1197
- if (agent !== undefined) commands.setAgent(agent)
1198
- if (agent !== undefined) skills.setAgent(agent)
1199
- await next.handle.dispose()
1200
- if (!quitting) renderCurrent()
1201
- throw error
1202
- }
1203
- // From here the new session is live: in-flight prepares/commands for
1204
- // the previous agent are stale and must be aborted and ignored.
1205
- abortPendingControllers()
1206
- epoch += 1
1207
- // No previous session (a bare launch switched straight into a resume):
1208
- // nothing to flush or dispose, so just confirm the activation.
1209
- if (previous === undefined) {
1210
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1211
- return
1212
- }
1213
- let cleanupWarning: string | undefined
1214
- try {
1215
- await sessions.flush(previous.session)
1216
- } catch (error: unknown) {
1217
- cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
1218
- }
1219
- try {
1220
- await previous.handle.dispose()
1221
- } catch (error: unknown) {
1222
- cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
1223
- }
1224
- bridge.notify(cleanupWarning === undefined
1225
- ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1226
- : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
1227
- cleanupWarning === undefined ? 'info' : 'warning')
1228
- })
1229
- }
1230
-
1231
- const switchQueue = new SessionSwitchQueue<PendingSwitch>(
1232
- async request => { if (!quitting) await activate(request.target) },
1233
- error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1234
- )
1235
-
1236
- const requestSwitch = (request: PendingSwitch): void => {
1237
- if (session === undefined) {
1238
- // No session yet (a bare launch using /resume before any input): activate
1239
- // the target directly — there is no running turn to wait on and nothing
1240
- // to flush.
1241
- void activate(request.target).catch((error: unknown) => {
1242
- bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1243
- })
1244
- return
1245
- }
1246
- if (request.target.sessionId === session.id) {
1247
- bridge.notify('that session is already active', 'warning')
1248
- return
1249
- }
1250
- const outcome = switchQueue.request(agent!, request)
1251
- if (outcome === 'queued') {
1252
- bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
1253
- }
1254
- }
1255
-
1256
- const resolveResumeId = async (wanted: string): Promise<string> => {
1257
- if (wanted === '') throw new Error('usage: /resume <id|prefix>')
1258
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1259
- const records = await sessionQuery.listSessions()
1260
- const exact = records.filter(record => record.header.id === wanted)
1261
- const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
1262
- if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
1263
- if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
1264
- const matched = matches[0]!
1265
- // Same lineage gate as the CLI --resume path and the picker.
1266
- if (isSubagentSession(matched.header)) {
1267
- throw new Error('subagent conversations are read-only; resume a root session')
1268
- }
1269
- if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
1270
- throw new Error('that session is already live in another owner')
1271
- }
1272
- return matched.header.id
1273
- }
1274
-
1275
- const requestResume = (wanted: string): void => {
1276
- void resolveResumeId(wanted).then(id => {
1277
- requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
1278
- }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
1279
- }
1280
-
1281
- const createSession = (mode?: string): void => {
1282
- // /new before any input is the first-session creation itself, not a switch.
1283
- if (session === undefined) {
1284
- ensureSession(mode)
1285
- return
1286
- }
1287
- const nextCwd = session.header.cwd ?? cwd
1288
- const id = `session-${randomUUID()}`
1289
- requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
1290
- }
1291
-
1292
- const reviewChanges = (argument: string): void => {
1293
- void loadGitDiff(session?.header.cwd ?? cwd, argument).then(({ title, files }) => {
1294
- try {
1295
- setPermissionAction('read-only')
1296
- } catch (error: unknown) {
1297
- bridge.notify(`review unavailable: ${error instanceof Error ? error.message : String(error)}`, 'error')
1298
- return
1299
- }
1300
- send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title), 'followup')
1301
- bridge.notify('review started under read-only permissions')
1302
- }, (error: unknown) => {
1303
- bridge.notify(`review failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1304
- })
1305
- }
1306
-
1307
- const forkSession = (argument: string): void => {
1308
- if (session === undefined || active === undefined) {
1309
- bridge.notify('no session yet - submit a message to start', 'warning')
1310
- return
1311
- }
1312
- try {
1313
- const text = argument.trim()
1314
- const atSeq = text === '' ? undefined : Number(text)
1315
- if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
1316
- throw new Error('usage: /fork [event-seq]')
1317
- }
1318
- const seed = selectForkSeed(session.events, atSeq)
1319
- const id = `session-${randomUUID()}`
1320
- requestSwitch({
1321
- target: {
1322
- sessionId: id,
1323
- resume: false,
1324
- mode: active.mode,
1325
- cwd: session.header.cwd ?? cwd,
1326
- seed: seed.events,
1327
- parentSession: session.id,
1328
- seedLength: seed.events.length,
1329
- },
1330
- label: id.slice(-12),
1331
- })
1332
- } catch (error: unknown) {
1333
- bridge.notify(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1334
- }
1335
- }
1336
-
1337
- const switchSession = (row: SessionRow): void => {
1338
- if (!row.resumable) {
1339
- bridge.notify('subagent conversations are read-only', 'warning')
1340
- return
1341
- }
1342
- requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
1343
- }
1344
-
1345
- const cancelSessionSwitch = (): boolean => {
1346
- return switchQueue.cancel()
1347
- }
1348
-
1349
- const appElement = (): ReturnType<typeof createElement> => {
1350
- // A bare launch mounts with pending/default model, mode, and permission
1351
- // facts until the first input composes the real session. These choices stay
1352
- // process-local and create no durable state before that composition.
1353
- const sessionCwd = session?.header.cwd ?? cwd
1354
- const currentView = store.getView()
1355
- const defaults = currentDefaults()
1356
- const model = currentView.model !== ''
1357
- ? currentView.model
1358
- : pendingSelection !== undefined
1359
- ? `${pendingSelection.provider}/${pendingSelection.model}`
1360
- : `${defaults.provider}/${defaults.model}`
1361
- const effort = resolveEffectiveSelection(
1362
- active?.selection.picked ?? pendingSelection,
1363
- session?.requestHeader()?.config,
1364
- defaults,
1365
- ).reasoningEffort
1366
- const permission = permissionPresets === undefined
1367
- ? currentView.permission
1368
- : effectivePermission(permissionPresets, session, pendingPermission)
1369
- return createElement(App, {
1370
- key: session?.id ?? 'pending',
1371
- store,
1372
- approval,
1373
- questions,
1374
- subagents,
1375
- commands,
1376
- skills,
1377
- model,
1378
- effort,
1379
- cwd: basename(sessionCwd),
1380
- workspaceRoot: sessionCwd,
1381
- branch: gitBranch(sessionCwd),
1382
- sessionId: session === undefined ? '' : session.id.slice(-8),
1383
- resumed: active?.resumed ?? false,
1384
- mode: active?.mode ?? pendingMode ?? presets.defaultId,
1385
- permission,
1386
- dispatch,
1387
- steer,
1388
- interrupt,
1389
- quit,
1390
- loadModels: () => loadModelDirectory(ctx),
1391
- loadModelProviders: () => loadProviderSettings(ctx),
1392
- subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1393
- saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1394
- saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
1395
- unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1396
- removeModelProvider: target => removeProviderSettings(ctx, target),
1397
- loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
1398
- subscribeProviderAuthorizations: listener => subscribeProviderAuthorizations(ctx, listener),
1399
- beginProviderAuthorization: (row, method, interaction, signal) => (
1400
- beginProviderAuthorization(ctx, row, method, interaction, signal)
1401
- ),
1402
- cancelProviderAuthorization: row => cancelProviderAuthorization(ctx, row.key),
1403
- logoutProviderAuthorization: row => logoutProviderAuthorization(ctx, row),
1404
- openAuthorizationUrl,
1405
- copyTextValue: copyText,
1406
- loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
1407
- inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
1408
- prepareImages: paths => saveImagePaths(paths, ctx.get('attachments')),
1409
- cyclePermission,
1410
- setPermission: setPermissionAction,
1411
- selectModel,
1412
- subagentModel: subagentModelLabel(),
1413
- setSubagentModel,
1414
- clearSubagentModel,
1415
- deleteSession,
1416
- exportTranscript,
1417
- renameTitle,
1418
- copyLastResponse,
1419
- loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
1420
- reviewChanges,
1421
- loadPresets: () => presets.list(),
1422
- switchMode: switchModeAction,
1423
- loadPermissions: () => permissionPresets === undefined
1424
- ? Promise.reject(new Error('permission presets are not mounted in this composition'))
1425
- : Promise.resolve(listPermissionRows(permissionPresets)),
1426
- createSession,
1427
- forkSession,
1428
- loadSessions,
1429
- loadSessionTranscript,
1430
- loadSubagents: () => {
1431
- const current = session
1432
- if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
1433
- return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
1434
- .then(rows => rows.filter(row => row.parent === current.id && row.subagent))
1435
- },
1436
- switchSession,
1437
- cancelSessionSwitch,
1438
- loadPlugins: () => listPluginRows(ctx),
1439
- loadJobs: () => listJobs(ctx, active?.agent),
1440
- statusline: statuslineItems,
1441
- saveStatusline,
1442
- saveTheme,
1443
- history: inputHistory,
1444
- recordHistory,
1445
- cancelQueued,
1446
- onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
1447
- })
1448
- }
1449
-
1450
- const renderCurrent = (): void => {
1451
- mountRef.current?.rerender(appElement())
1452
- }
1453
-
1454
- mountRef.current = io.mount(appElement())
1455
-
1456
- // Startup prompt/images use the same durable delivery path as composer
1457
- // submissions. Image bytes are committed before the user/message event.
1458
- if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
1459
- if ((startup.images?.length ?? 0) > 0) {
1460
- bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
1461
- }
1462
- void saveImagePaths(startup.images ?? [], ctx.get('attachments')).then(
1463
- images => {
1464
- if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
1465
- send(startup.prompt ?? '', 'followup', images)
1466
- },
1467
- (error: unknown) => bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1468
- )
1469
- }
1470
-
1471
- async function copyLastResponse(): Promise<string> {
1472
- const text = latestAssistantText(store.getView())
1473
- if (text === undefined) return 'nothing to copy yet'
1474
- await copyText(text)
1475
- return 'copied latest response'
1476
- }
1477
-
1478
- // A corrupt statusline config must not vanish silently: surface it once
1479
- // the notice channel is live, after the first frame settles.
1480
- if (statuslineWarning !== undefined) {
1481
- setTimeout(() => {
1482
- bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
1483
- }, 50)
1484
- }
1485
- // Same one-shot surface for a corrupt theme file (dark fallback stays live).
1486
- if (themeWarning !== undefined) {
1487
- setTimeout(() => {
1488
- bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
1489
- }, 50)
1490
- }
1491
- }
1492
-
1493
- /**
1494
- * Mount the interactive terminal driver.
1495
- * @param ctx - plugin context carrying core services and the launcher-provided exit request.
1496
- * @param config - validated startup config resolved from the tuiStartup provider.
1497
- */
1498
- export function apply(ctx: Context, config: Config): void {
1499
- // The CLI validated --theme at parse time; the loose config schema falls
1500
- // back to dark for anything unexpected.
1501
- const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
1502
- const input = {
1503
- ...(theme === undefined ? {} : { theme }),
1504
- ...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
1505
- ...(config.startup.images === undefined ? {} : { images: config.startup.images }),
1506
- }
1507
- const startup: TuiStartup =
1508
- config.startup.kind === 'resume' && config.startup.sessionId !== undefined
1509
- ? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
1510
- : config.startup.kind === 'latest'
1511
- ? { kind: 'latest', ...input }
1512
- : config.startup.kind === 'named' && config.startup.sessionId !== undefined
1513
- ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
1514
- : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
1515
- // Read through the global service store, not the property proxy: appExit is
1516
- // an optional host value, never an injected dependency.
1517
- const exit = ctx.get('appExit')
1518
- if (exit === undefined) {
1519
- throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
1520
- }
1521
- const io: TuiIo = { mount: internals.mount, exit }
1522
- void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
1523
- }
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, rm, stat, 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 type {} from '@deepseek-ai/dsh-attachment'
24
+ import { createUserMessage, MessageId, type ContentBlock, type ImageBlock } from '@deepseek-ai/dsh-llm'
25
+ import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
26
+ import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
27
+ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
28
+ // Type-only: carries the ctx.sessionTitle service merge for /title.
29
+ import type {} from '@deepseek-ai/dsh-session-title'
30
+ // Empty type imports carry the loader Context merge for the settlement await
31
+ // and the cmdline Context merge for the appExit host value.
32
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
33
+ import type {} from '@deepseek-ai/dsh-cmdline'
34
+ import { App, type NoticeTone } from './app.ts'
35
+ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
36
+ import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
37
+ import { internals, type TuiMount } from './internals.ts'
38
+ import { syncModelCapabilities } from './model-capabilities.ts'
39
+ import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
40
+ import {
41
+ loadProviderSettings,
42
+ removeProviderSettings,
43
+ saveProviderCredential,
44
+ saveProviderConfiguration,
45
+ subscribeProviderSettings,
46
+ unsetProviderCredential,
47
+ } from './provider-settings.ts'
48
+ import { createMentions, type MentionsApi } from './mentions.ts'
49
+ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
50
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
51
+ import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
52
+ import { parseStatuslineItems } from './render/status.ts'
53
+ import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
54
+ import { watchSkills, type SkillsView } from './skills.ts'
55
+ import { toolArgumentsPreview } from './render/tool-preview.ts'
56
+ import { buildExportMarkdown } from './render/export.ts'
57
+ import { inspectImagePaths, saveImagePaths } from './attachments.ts'
58
+ import { copyText, latestAssistantText } from './editor.ts'
59
+ import { applyCtrlRPassthrough, resolveEditorKeysStartupHint, type EditorKeysEnv } from './editor-keys.ts'
60
+ import {
61
+ beginProviderAuthorization,
62
+ cancelProviderAuthorization,
63
+ loadProviderAuthorizations,
64
+ logoutProviderAuthorization,
65
+ openAuthorizationUrl,
66
+ subscribeProviderAuthorizations,
67
+ } from './authorization.ts'
68
+ import { selectForkSeed } from './fork.ts'
69
+ import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
70
+ import type { TuiStartup } from './startup.ts'
71
+ import { SessionSwitchQueue } from './session-switch.ts'
72
+ import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
73
+ import {
74
+ applyPendingPermission,
75
+ cyclePermission as cyclePermissionPreset,
76
+ effectivePermission,
77
+ listPermissionRows,
78
+ permissionPresetsFrom,
79
+ selectPermission,
80
+ } from './permissions.ts'
81
+ import { listPluginRows } from './plugin-inventory.ts'
82
+ import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
83
+ import {
84
+ isSubagentSession,
85
+ matchSessionId,
86
+ mergeSessionTitles,
87
+ newestRootForCwd,
88
+ planSessionDeletion,
89
+ projectSessionRows,
90
+ SESSION_ARTIFACT_NAMES,
91
+ sessionArtifactDirectory,
92
+ type SessionDirectoryOptions,
93
+ type SessionQueryService,
94
+ type SessionRow,
95
+ } from './session-directory.ts'
96
+ import { createUserSettingsPersistence } from './settings-file.ts'
97
+
98
+ /** Stable Cordis plugin name. */
99
+ export const name = 'tui-runner'
100
+
101
+ /** Core services required before the interactive session can start. */
102
+ export const inject = ['agentDefaultModel', 'agents', 'sessions']
103
+
104
+ /** Plugin config: the startup resolved from this app's injected provider service. */
105
+ export interface Config {
106
+ /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
107
+ startup: { kind: string; sessionId?: string; mode?: string; theme?: string; prompt?: string; images?: string[] }
108
+ }
109
+
110
+ export const Config: z<Config> = z.object({
111
+ startup: z.object({
112
+ kind: z.string().required(),
113
+ sessionId: z.string(),
114
+ mode: z.string(),
115
+ theme: z.string(),
116
+ prompt: z.string(),
117
+ images: z.array(z.string()),
118
+ }),
119
+ })
120
+
121
+ /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
122
+ interface TuiIo {
123
+ mount: typeof internals.mount
124
+ exit(code: number): void
125
+ }
126
+
127
+ /** Report an unexpected direct-driver failure and request a failing exit. */
128
+ function fail(io: TuiIo, error: unknown): void {
129
+ internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
130
+ io.exit(1)
131
+ }
132
+
133
+ /**
134
+ * Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
135
+ * started through run_in_background are fenced by their owner, so the CURRENT
136
+ * agent is the caller. A missing registry is a harmless absence (the base
137
+ * composition may not mount one) and collapses to the empty panel state —
138
+ * the documented degradation for harmless probes, not an error.
139
+ * @param ctx - context carrying the optional `jobs` registry.
140
+ * @param caller - the active agent (undefined sees only unowned jobs).
141
+ * @returns job rows in registration order; never throws.
142
+ */
143
+ function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./kernel-panels.ts').JobRow[] {
144
+ const jobs = ctx.get('jobs')
145
+ if (jobs === undefined) return []
146
+ try {
147
+ return jobs.list(caller).map((job: JobSnapshot) => ({
148
+ id: job.id,
149
+ kind: job.kind,
150
+ label: job.label,
151
+ status: job.status,
152
+ detail: job.detail,
153
+ startedAt: job.startedAt,
154
+ finishedAt: job.finishedAt,
155
+ }))
156
+ } catch {
157
+ return []
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolve the working directory's git branch for the status line.
163
+ * @param cwd - the session's working directory.
164
+ * @returns the branch name, or '' outside a repository or on a detached HEAD.
165
+ */
166
+ function gitBranch(cwd: string): string {
167
+ try {
168
+ const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
169
+ return ref?.[1] ?? ''
170
+ } catch {
171
+ // Only the single HEAD read is attempted, so the sole reachable failure is
172
+ // a missing repository (or unreadable HEAD file): the branch group drops out.
173
+ return ''
174
+ }
175
+ }
176
+
177
+ /** The session identity this invocation will run, plus whether it is resumed. */
178
+ interface Target {
179
+ sessionId: string
180
+ resume: boolean
181
+ mode?: string
182
+ cwd?: string
183
+ seed?: readonly SessionEvent[]
184
+ parentSession?: SessionId
185
+ seedLength?: number
186
+ }
187
+
188
+ /**
189
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
190
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
191
+ * user text: path separators must never leak into the default export filename
192
+ * (which would escape the session cwd).
193
+ * @param id - the session id.
194
+ * @returns at most the last 8 filename-safe characters.
195
+ */
196
+ export function exportSessionIdSuffix(id: string): string {
197
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
198
+ }
199
+
200
+ /** One ordered step of the terminal quit cleanup. */
201
+ export interface QuitCleanupStep {
202
+ /** Step label used in diagnostics and tests. */
203
+ readonly name: string
204
+ /** The step's async work; a rejection is contained by the sequence. */
205
+ readonly run: () => Promise<void>
206
+ }
207
+
208
+ /**
209
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
210
+ * contained (reported through `onError`) so a failed flush or dispose never
211
+ * skips the remaining cleanup; the exit request is always reached exactly
212
+ * once.
213
+ * @param steps - the cleanup steps in dependency order (settle the visible
214
+ * session, await the final in-flight composition, await durable recall).
215
+ * @param exit - the terminal exit request (code 0).
216
+ * @param onError - optional failure sink; called once per failing step and
217
+ * itself contained, so a throwing sink cannot abort the sequence.
218
+ * @returns the names of the steps that started, in order (for tests).
219
+ */
220
+ export async function runQuitSequence(
221
+ steps: readonly QuitCleanupStep[],
222
+ exit: (code: number) => void,
223
+ onError?: (name: string, error: unknown) => void,
224
+ ): Promise<readonly string[]> {
225
+ const started: string[] = []
226
+ for (const step of steps) {
227
+ started.push(step.name)
228
+ try {
229
+ await step.run()
230
+ } catch (error) {
231
+ try {
232
+ onError?.(step.name, error)
233
+ } catch {
234
+ // The failure sink must never abort the cleanup sequence.
235
+ }
236
+ }
237
+ }
238
+ try {
239
+ exit(0)
240
+ } catch {
241
+ // The exit request itself must not become an unhandled rejection.
242
+ }
243
+ return started
244
+ }
245
+
246
+ /**
247
+ * Resolve the invocation's target session against the persisted headers.
248
+ * @param startup - the parsed startup flags.
249
+ * @param persistence - the persistence service; required for resume/latest.
250
+ * @param cwd - the working directory `--continue` filters by.
251
+ * @returns the target identity.
252
+ * @throws with a user-facing message when the flags name nothing resolvable.
253
+ */
254
+ export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
255
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
256
+ if (startup.kind === 'named') {
257
+ // The id must not exist yet: reject before any Agent composition when the
258
+ // backend can tell us (a live collision is still caught by the session
259
+ // store at create time).
260
+ if (persistence !== undefined) {
261
+ const headers: readonly SessionHeader[] = await persistence.list()
262
+ if (headers.some(header => header.id === startup.sessionId)) {
263
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
264
+ }
265
+ }
266
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
267
+ }
268
+ if (persistence === undefined) {
269
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
270
+ }
271
+ const headers: readonly SessionHeader[] = await persistence.list()
272
+ if (startup.kind === 'resume') {
273
+ const matched = matchSessionId(headers, startup.sessionId)
274
+ // Subagent conversations are read-only everywhere else; the CLI must not
275
+ // be a back door into appending root turns to a child's durable log.
276
+ if (isSubagentSession(matched)) {
277
+ throw new Error('subagent conversations are read-only; resume a root session')
278
+ }
279
+ return { sessionId: matched.id, resume: true }
280
+ }
281
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
282
+ const newest = newestRootForCwd(headers, cwd)
283
+ if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
284
+ return { sessionId: newest.id, resume: true }
285
+ }
286
+
287
+ /**
288
+ * Resolve a bounded command preview for one pending approval: the request
289
+ * contract carries no arguments, so the bar self-serves from the transcript
290
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
291
+ * @param events - the transcript entries to search.
292
+ * @param callId - the tool call the question is about, when the asker had one.
293
+ * @param toolName - the tool the question is about.
294
+ * @returns a bounded preview line, '' when nothing useful resolves.
295
+ */
296
+ function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
297
+ if (callId === undefined) return ''
298
+ const entry = events.find(candidate =>
299
+ candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
300
+ if (entry === undefined) return ''
301
+ const args = (entry as { arguments?: string }).arguments ?? ''
302
+ return toolArgumentsPreview(args, toolName)
303
+ }
304
+
305
+ /** The runner's connection between the React app and the process side. */
306
+ interface AppBridge {
307
+ /** Post one local notice line (feedback the transcript does not carry). */
308
+ notify(text: string, tone?: NoticeTone): void
309
+ }
310
+
311
+ /**
312
+ * Run the interactive terminal session: resolve the target session, create or
313
+ * resume one Agent, mount the app, and keep the process alive until the user
314
+ * quits.
315
+ * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
316
+ * @param startup - the parsed invocation flags.
317
+ * @param io - process-facing effects.
318
+ */
319
+ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
320
+ // Loader siblings mount concurrently. Await the complete application before
321
+ // creating an Agent so its scoped tools and adapters are not half-composed.
322
+ await ctx.get('loader')?.await()
323
+ const agents = ctx.get('agents')
324
+ const defaultModel = ctx.get('agentDefaultModel')
325
+ const sessions = ctx.get('sessions')
326
+ const persistence = ctx.get('sessionPersistence')
327
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
328
+ // Early process shutdown can dispose the tree while settlement is pending.
329
+ if (agents === undefined || defaultModel === undefined || sessions === undefined) return
330
+
331
+ const cwd = process.cwd()
332
+ // Live deployment default (web selectModel parity): read on every use, not
333
+ // snapshotted at launch, so a /model pick this process saves becomes the
334
+ // default for sessions composed afterwards without a restart.
335
+ const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
336
+ const presets = agentPresetsFrom(ctx)
337
+ if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
338
+ const permissionPresets = permissionPresetsFrom(ctx)
339
+
340
+ // A bare fresh launch stays transient: no Agent or session is composed, and
341
+ // nothing is persisted, until the user's first real input. Explicit flags
342
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
343
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
344
+
345
+ interface ActiveSession {
346
+ handle: AgentHandle
347
+ agent: Agent
348
+ session: Session
349
+ store: ReturnType<typeof createTranscriptStore>
350
+ mentions: MentionsApi
351
+ mode: string
352
+ selection: { picked?: ModelSelection }
353
+ resumed: boolean
354
+ }
355
+
356
+ /** Prepare a complete next session before disturbing the currently visible one. */
357
+ const prepare = async (next: Target): Promise<ActiveSession> => {
358
+ const nextCwd = next.cwd ?? cwd
359
+ // A bare launch can pick a model before any session exists: the process
360
+ // keeps that explicit choice and every prepared session starts from it
361
+ // (the documented precedence: explicit pick > session header > default).
362
+ const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
363
+ ? {}
364
+ : { picked: pendingSelection }
365
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode
366
+ if (!next.resume) mode = (await presets.resolve(mode)).id
367
+ const setup = async (agentCtx: Context): Promise<void> => {
368
+ const sessionPreset = next.resume
369
+ ? resolvePreset(agentCtx.agent!.session)
370
+ : mode
371
+ const mounted = await presets.mount(agentCtx, sessionPreset)
372
+ mode = mounted.id
373
+ const selection: ModelSelectionRef = {
374
+ get current(): ModelSelection | undefined {
375
+ return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
376
+ },
377
+ set current(value: ModelSelection | undefined) { selectionState.picked = value },
378
+ assembled: undefined,
379
+ }
380
+ installModelSelection(agentCtx, selection)
381
+ }
382
+ // AgentOptions seed the loop's fallback route; effort rides the selection
383
+ // ref (installModelSelection), so only the provider/model pair is seeded.
384
+ const seedOptions = pendingSelection === undefined
385
+ ? { provider: currentDefaults().provider, model: currentDefaults().model }
386
+ : { provider: pendingSelection.provider, model: pendingSelection.model }
387
+ const handle = next.resume
388
+ ? await agents.resume({
389
+ resumeSessionId: SessionId(next.sessionId),
390
+ agentOptions: seedOptions,
391
+ // Quit aborts an in-flight composition so the exit wait never hangs
392
+ // on a prepare that cannot settle; upstream rolls the creation back.
393
+ signal: quitAbort.signal,
394
+ setup,
395
+ })
396
+ : await agents.create({
397
+ sessionId: SessionId(next.sessionId),
398
+ meta: {
399
+ cwd: nextCwd,
400
+ agentPreset: mode,
401
+ ...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
402
+ ...(next.seedLength === undefined ? {} : { seedLength: next.seedLength }),
403
+ },
404
+ ...(next.seed === undefined ? {} : { seed: next.seed }),
405
+ agentOptions: seedOptions,
406
+ signal: quitAbort.signal,
407
+ setup,
408
+ })
409
+ const session = handle.agent.session
410
+ if (!next.resume && permissionPresets !== undefined) {
411
+ applyPendingPermission(permissionPresets, session, pendingPermission)
412
+ }
413
+ return {
414
+ handle,
415
+ agent: handle.agent,
416
+ session,
417
+ store: createTranscriptStore(session.events),
418
+ mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
419
+ mode: mode ?? 'standard',
420
+ selection: selectionState,
421
+ resumed: next.resume,
422
+ }
423
+ }
424
+
425
+ let active: ActiveSession | undefined
426
+ let agent: Agent | undefined
427
+ let session: Session | undefined
428
+ let store: TranscriptStore = createTranscriptStore()
429
+ // Live subagent activity (child sessions of the current root): one bounded
430
+ // row per child, folded from the same event bus the transcript feeds on.
431
+ const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
432
+ // Pre-session @file completion runs the official search over the launch
433
+ // cwd (model- and session-independent); the prepare/activate paths replace
434
+ // this with the agent-scoped instance once a session exists.
435
+ let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
436
+ /** Explicit model pick made before any session exists (a bare launch). */
437
+ let pendingSelection: ModelSelection | undefined
438
+ /** Agent preset selected before the first session exists. */
439
+ let pendingMode: string | undefined
440
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
441
+ let pendingModeWork: Promise<void> = Promise.resolve()
442
+ /** Permission preset selected before the first session exists. */
443
+ let pendingPermission: string | undefined
444
+ /**
445
+ * Monotonic session epoch: bumped on every successful activation, on every
446
+ * first-session creation, and on quit. Async callbacks (mention prepares,
447
+ * command executions) capture it at call time and drop their result when it
448
+ * changed, so a stale callback can never deliver to an agent that is no
449
+ * longer on screen.
450
+ */
451
+ let epoch = 0
452
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
453
+ const quitAbort = new AbortController()
454
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
455
+ const pendingControllers = new Set<AbortController>()
456
+ const abortPendingControllers = (): void => {
457
+ for (const controller of [...pendingControllers]) {
458
+ pendingControllers.delete(controller)
459
+ controller.abort()
460
+ }
461
+ }
462
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
463
+ let composing: Promise<void> | undefined
464
+ /**
465
+ * Run one session composition exclusively: concurrent compositions wait
466
+ * their turn, so a bare-launch first-session creation and a /resume
467
+ * activation can never compose agents in parallel (the loser would leak its
468
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
469
+ * always continues.
470
+ */
471
+ const compose = (work: () => Promise<void>): Promise<void> => {
472
+ const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
473
+ composing = turn.catch(() => {})
474
+ return turn
475
+ }
476
+
477
+ if (!lazy) {
478
+ const target = await resolveTarget(startup, persistence, cwd)
479
+ const prepared = await prepare(target)
480
+ active = prepared
481
+ agent = prepared.agent
482
+ session = prepared.session
483
+ store = prepared.store
484
+ mentions = prepared.mentions
485
+ }
486
+
487
+ // Seed the transcript from the full session log: constructor seeds never
488
+ // fire on `session/event`, so a resumed session paints its history once
489
+ // before the first render. The handler reads the current session/store, so
490
+ // the deferred first session of a bare launch is covered by the same feed.
491
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
492
+ if (session === undefined) return
493
+ if (subject.id === session.id) {
494
+ store.apply(event)
495
+ return
496
+ }
497
+ // Child sessions (subagent conversations this root spawned) fold into
498
+ // the bounded live-activity feed, never the transcript: the root stays
499
+ // the only durable transcript truth while a running subagent remains
500
+ // visible. Lineage comes from the child header, same field the session
501
+ // directory uses to tag `↳` rows.
502
+ if (subject.header.parentSession === session.id && subject.header.origin === 'subagent') subagents.apply(subject.id, event)
503
+ })
504
+
505
+ const commands: CommandsView = watchCommands(ctx)
506
+ if (agent !== undefined) commands.setAgent(agent)
507
+
508
+ const skills: SkillsView = watchSkills(ctx)
509
+ if (agent !== undefined) skills.setAgent(agent)
510
+
511
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
512
+ // claimed, every other ask falls through to the fail-closed waterfall. The
513
+ // owner predicate is empty until the first session exists.
514
+ const approval: ApprovalStore = mountApprovalAnswerer(
515
+ ctx,
516
+ candidate => agent !== undefined && candidate.id === agent.id,
517
+ request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
518
+ )
519
+
520
+ // Subagent model routing. The kernel seeds child agents from the parent's
521
+ // CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
522
+ // /model switch never touches delegated work would keep running on the
523
+ // launch-time route. This plugin-level listener mirrors installModelSelection
524
+ // for subagent-origin requests (scope filtering delivers the agent subject
525
+ // inside the payload): the explicit /subagent override wins, else the root's
526
+ // effective selection (explicit pick > session header > deployment default).
527
+ // Effort rides the selection exactly like the kernel listener applies it.
528
+ let subagentOverride: ModelSelection | undefined
529
+ ctx.on('agent/request', (payload, next) => {
530
+ const subject = payload.agent
531
+ const header = subject.session.header
532
+ if (header.parentSession === undefined && header.origin !== 'subagent') return next()
533
+ const picked = subagentOverride
534
+ ?? resolveEffectiveSelection(
535
+ active?.selection.picked ?? pendingSelection,
536
+ subject.session.requestHeader()?.config,
537
+ currentDefaults(),
538
+ )
539
+ return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
540
+ })
541
+
542
+ // ask_user_question provider: the single UI provider on the shared service,
543
+ // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
544
+ // through this same pipe.
545
+ const questions: QuestionStore = mountQuestionProvider(ctx)
546
+
547
+ // The bridge the React app registers on mount: local notices from the
548
+ // process side (unknown commands, switch confirmations, cancels).
549
+ const bridge: AppBridge = { notify: () => {} }
550
+
551
+ // Same-id capability inheritance. Catalog capabilities flow by route key,
552
+ // not model id, so a hand-declared relay model without an explicit
553
+ // reasoningEfforts declaration serves no reasoning levels and offers no
554
+ // effort picker. This background pass materializes declarations from
555
+ // same-id donors (sibling settings entries first, then other routes'
556
+ // advertised levels) over the panel's settings.mutate path, where the
557
+ // upstream serviceability gate still rejects invalid writes atomically.
558
+ // The debounce coalesces the settings/adapters event pair; the applier
559
+ // skips only a same-source same-revision echo of its own write, so the
560
+ // loop converges without ever ignoring a real external edit.
561
+ const capabilitySyncDebounceMs = 400
562
+ const runCapabilitySync = (): void => {
563
+ void syncModelCapabilities(ctx, bridge.notify)
564
+ }
565
+ let capabilitySyncTimer: ReturnType<typeof setTimeout> | undefined
566
+ const scheduleCapabilitySync = (): void => {
567
+ if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
568
+ capabilitySyncTimer = setTimeout(() => {
569
+ capabilitySyncTimer = undefined
570
+ runCapabilitySync()
571
+ }, capabilitySyncDebounceMs)
572
+ }
573
+ const offCapabilitySync = [
574
+ ctx.on('settings/document-updated', scheduleCapabilitySync),
575
+ ctx.on('llm/adapters-updated', scheduleCapabilitySync),
576
+ ]
577
+ scheduleCapabilitySync()
578
+
579
+ // /statusline persistence: one user-level JSON file under the DSH home.
580
+ // Missing file means defaults; a corrupt file degrades to defaults with a
581
+ // surfaced warning (the customization is user-authored, never silent).
582
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
583
+ let statuslineWarning: string | undefined
584
+ let statuslineItems: readonly string[] = []
585
+ try {
586
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
587
+ } catch (error) {
588
+ statuslineItems = parseStatuslineItems(undefined)
589
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
590
+ statuslineWarning = error instanceof Error ? error.message : String(error)
591
+ }
592
+ }
593
+ // Serialized, crash-atomic writes for the user-level JSON files: the chain
594
+ // orders rapid consecutive saves (the LAST snapshot wins on disk), each
595
+ // write goes through a sibling temp file + rename, and quit waits for the
596
+ // flush exactly like it waits for the recall history.
597
+ const settingsPersistence = createUserSettingsPersistence()
598
+ const saveStatusline = (items: readonly string[]): void => {
599
+ statuslineItems = [...items]
600
+ void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
601
+ .catch((writeError: unknown) => {
602
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
603
+ })
604
+ }
605
+
606
+ // /vscode-keys: detect the hosting editor's user keybindings.json and pass
607
+ // Ctrl+R through the workbench. One marker file under the DSH home keeps
608
+ // the startup hint a once-per-install event.
609
+ const editorKeysEnv: EditorKeysEnv = {
610
+ env: process.env,
611
+ paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
612
+ flagPath: join(homedir(), '.dsh', 'dsh-code', 'editor-keys.json'),
613
+ }
614
+ const applyEditorKeys = (): Promise<string> => applyCtrlRPassthrough(editorKeysEnv)
615
+
616
+ // /theme persistence: one user-level JSON file under the DSH home, mirroring
617
+ // the statusline file. A missing file means the dark default; a corrupt file
618
+ // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
619
+ // auto detection > dark (auto detection itself is a later enhancement and
620
+ // currently falls back to dark inside theme.ts).
621
+ const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
622
+ let themeWarning: string | undefined
623
+ if (startup.theme === undefined) {
624
+ try {
625
+ setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
626
+ } catch (error) {
627
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
628
+ themeWarning = error instanceof Error ? error.message : String(error)
629
+ }
630
+ }
631
+ } else {
632
+ setTheme(startup.theme)
633
+ }
634
+ const saveTheme = (name: ThemeName): void => {
635
+ setTheme(name)
636
+ void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
637
+ .catch((writeError: unknown) => {
638
+ bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
639
+ })
640
+ }
641
+
642
+ // Global input recall (Codex composer-history contract): one JSONL file
643
+ // under the DSH home. A missing file means an empty history; unreadable or
644
+ // corrupt content degrades to the valid lines it could parse, silently —
645
+ // recall is a convenience surface, never a gate.
646
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
647
+ let inputHistory: readonly string[] = []
648
+ try {
649
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
650
+ } catch {
651
+ inputHistory = []
652
+ }
653
+ /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
654
+ let historyWriteChain: Promise<void> = Promise.resolve()
655
+ const recordHistory = (text: string): void => {
656
+ if (text === '') return
657
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
658
+ // Write the whole current list, serialized per submission: the file is
659
+ // never read back on the submit path, so rapid same-process submissions
660
+ // cannot lose entries to a read-modify-write race.
661
+ historyWriteChain = historyWriteChain
662
+ .then(() => mkdir(dirname(historyPath), { recursive: true }))
663
+ .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
664
+ .catch((writeError: unknown) => {
665
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
666
+ })
667
+ }
668
+
669
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
670
+ const cancelQueued = (messageId: string): void => {
671
+ if (agent === undefined) return
672
+ try {
673
+ if (agent.inbox.remove(MessageId(messageId))) {
674
+ bridge.notify('queued message cancelled')
675
+ }
676
+ } catch (error: unknown) {
677
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
678
+ }
679
+ }
680
+
681
+ // The mount handle lives in a box: quit closes over it, while the mount
682
+ // itself is created after quit (the App element needs quit as a prop).
683
+ const mountRef: { current?: TuiMount } = {}
684
+ let quitting = false
685
+ const quit = (): void => {
686
+ if (quitting) return
687
+ quitting = true
688
+ switchQueue.cancel()
689
+ // Stale prepares/commands die with the session they were for. Aborting
690
+ // the composition signal lets a never-settling prepare reject, so the
691
+ // exit wait below cannot hang (upstream rolls the creation back).
692
+ abortPendingControllers()
693
+ quitAbort.abort()
694
+ epoch += 1
695
+ off()
696
+ for (const dispose of offCapabilitySync) dispose()
697
+ if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
698
+ mountRef.current?.unmount()
699
+ const currentSession = session
700
+ const currentActive = active
701
+ const report = (name: string, error: unknown): void => {
702
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
703
+ }
704
+ // One ordered cleanup: settle the visible session (if any a bare launch
705
+ // that never composed one resolves immediately), then wait for the final
706
+ // in-flight composition (its work swallows errors and the quitting guard
707
+ // disposes any half-prepared agent), then flush the durable recall and
708
+ // the queued user-level settings writes, then request exit. `composing`
709
+ // and `historyWriteChain` are read at step run
710
+ // time, so a turn that was still being queued when quit ran is included.
711
+ // A failing step must never skip the remaining cleanup.
712
+ const steps: QuitCleanupStep[] = [
713
+ ...(currentSession === undefined || currentActive === undefined
714
+ ? []
715
+ : [
716
+ { name: 'flush', run: async () => { await sessions.flush(currentSession) } },
717
+ { name: 'dispose', run: () => currentActive.handle.dispose() },
718
+ ]),
719
+ { name: 'composing', run: () => composing ?? Promise.resolve() },
720
+ { name: 'history', run: () => historyWriteChain },
721
+ { name: 'settings', run: () => settingsPersistence.flush() },
722
+ ]
723
+ void runQuitSequence(steps, io.exit, report)
724
+ }
725
+
726
+ /** Run one slash line through the command registry (closed namespace). */
727
+ const runSlash = (line: string): void => {
728
+ const currentAgent = agent
729
+ if (currentAgent === undefined) return
730
+ if (line.startsWith('/resume ')) {
731
+ requestResume(line.slice(8).trim())
732
+ return
733
+ }
734
+ const registry = ctx.get('commands')
735
+ if (registry === undefined) {
736
+ bridge.notify('no command registry is mounted in this composition', 'error')
737
+ return
738
+ }
739
+ const controller = new AbortController()
740
+ const atEpoch = epoch
741
+ pendingControllers.add(controller)
742
+ const finish = (): void => {
743
+ pendingControllers.delete(controller)
744
+ }
745
+ // rc.8 registry.execute gained an `images` admission parameter; the TUI
746
+ // composer never attaches images to a slash line, so every invocation is
747
+ // the empty batch (commands declaring input.images still run image-free).
748
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, [], controller.signal)).then((execution) => {
749
+ finish()
750
+ // A switch/quit landed while the command ran: its fall-through must not
751
+ // reach an agent that is no longer on screen.
752
+ if (epoch !== atEpoch || agent !== currentAgent) return
753
+ if (execution === undefined) {
754
+ // No command owns this line: send it verbatim so a user-invocable
755
+ // skill gesture (`/skill-name`) reaches the host's tool-skill
756
+ // pre-step injection the web composer's same fall-through.
757
+ try {
758
+ currentAgent.followup(createUserMessage({
759
+ content: [{ type: 'text', text: line }],
760
+ source: { kind: 'user' },
761
+ }))
762
+ } catch (error: unknown) {
763
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
764
+ }
765
+ }
766
+ }, (error: unknown) => {
767
+ finish()
768
+ if (epoch !== atEpoch || agent !== currentAgent) return
769
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
770
+ })
771
+ }
772
+
773
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
774
+ const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
775
+ const currentAgent = agent!
776
+ const currentMentions = mentions!
777
+ // The command registry is a closed namespace: slash lines run out of
778
+ // band and never reach the model through this path (steering keeps the
779
+ // registry out of the inbox, so slash lines steer as literal text).
780
+ if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
781
+ runSlash(line)
782
+ return
783
+ }
784
+ let parsed: ReturnType<MentionsApi['parse']>
785
+ try {
786
+ parsed = currentMentions.parse(line)
787
+ } catch (error: unknown) {
788
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
789
+ return
790
+ }
791
+ const atEpoch = epoch
792
+ const deliver = (readable: string, context?: UserMessage): void => {
793
+ // A switch/quit landed while the snapshot was being prepared: never
794
+ // deliver to an agent that is no longer on screen.
795
+ if (epoch !== atEpoch || agent !== currentAgent) return
796
+ // Session snapshots ride the inbox as model-facing context ahead of
797
+ // the readable message (upstream README wiring: inject before the
798
+ // followup/steer that wakes the driver).
799
+ try {
800
+ if (context !== undefined) currentAgent.inject(context)
801
+ const content: ContentBlock[] = [
802
+ ...(readable === '' ? [] : [{ type: 'text' as const, text: readable }]),
803
+ ...images,
804
+ ]
805
+ const message = createUserMessage({
806
+ content,
807
+ source: { kind: 'user' },
808
+ })
809
+ if (mode === 'steer') {
810
+ // The queued message is visible as a pending transcript row (the
811
+ // web queue-mirror contract); no notice noise on the happy path.
812
+ currentAgent.steer(message)
813
+ } else {
814
+ currentAgent.followup(message)
815
+ }
816
+ } catch (error: unknown) {
817
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
818
+ }
819
+ }
820
+ if (parsed.references.length === 0) {
821
+ deliver(parsed.text)
822
+ return
823
+ }
824
+ const controller = new AbortController()
825
+ pendingControllers.add(controller)
826
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
827
+ pendingControllers.delete(controller)
828
+ deliver(prepared.text, prepared.additionalContext)
829
+ }, (error: unknown) => {
830
+ pendingControllers.delete(controller)
831
+ if (controller.signal.aborted || epoch !== atEpoch) return
832
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
833
+ })
834
+ }
835
+
836
+ // Deferred first-session creation for a bare launch: the session is composed
837
+ // only when the user submits real input (or /new), and every line that
838
+ // arrives during creation is delivered in order afterwards. A creation
839
+ // failure reports and clears the queue, leaving the transient state ready
840
+ // for the next attempt.
841
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer'; images: readonly ImageBlock[] }> = []
842
+ // A creation is queued/running: further submissions must not mint more
843
+ // fresh sessions (their lines queue into pendingInputs instead).
844
+ let creating = false
845
+ const ensureSession = (mode?: string): void => {
846
+ if (creating) return
847
+ creating = true
848
+ void compose(async () => {
849
+ try {
850
+ // A direct `/mode <preset>` resolves asynchronously. Preserve submit
851
+ // order so the first composition cannot race ahead with the old mode.
852
+ await pendingModeWork
853
+ // Another composition (e.g. a /resume activated while this creation
854
+ // waited its turn) may have published a session already: deliver the
855
+ // queued lines there instead of minting a competing fresh session
856
+ // (which would orphan the live one without a dispose).
857
+ if (session !== undefined) {
858
+ const queued = pendingInputs.splice(0)
859
+ for (const item of queued) deliverLine(item.text, item.mode, item.images)
860
+ return
861
+ }
862
+ const next = await prepare({
863
+ sessionId: `session-${randomUUID()}`,
864
+ resume: false,
865
+ ...(mode === undefined ? {} : { mode }),
866
+ })
867
+ if (quitting) {
868
+ void next.handle.dispose().catch(() => {})
869
+ return
870
+ }
871
+ active = next
872
+ agent = next.agent
873
+ session = next.session
874
+ store = next.store
875
+ mentions = next.mentions
876
+ subagents.reset()
877
+ pendingMode = undefined
878
+ pendingPermission = undefined
879
+ commands.setAgent(agent)
880
+ skills.setAgent(agent)
881
+ // The App mounts with a placeholder key until the first input; the
882
+ // key-change remount below must start from a clean screen or the ghost
883
+ // static header stays visible above the new one (same source-backed
884
+ // clear the session-switch path performs).
885
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
886
+ renderCurrent()
887
+ abortPendingControllers()
888
+ epoch += 1
889
+ const queued = pendingInputs.splice(0)
890
+ for (const item of queued) deliverLine(item.text, item.mode, item.images)
891
+ } finally {
892
+ creating = false
893
+ }
894
+ }).catch((error: unknown) => {
895
+ pendingInputs.length = 0
896
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
897
+ })
898
+ }
899
+
900
+ /** Deliver one readable line to the agent, expanding session mentions first. */
901
+ const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
902
+ const line = text.trim()
903
+ if (line === '' && images.length === 0) return
904
+ if (images.length === 0 && line.startsWith('/mode ')) {
905
+ void switchModeAction(line.slice(6).trim()).then(
906
+ selected => bridge.notify(`mode → ${selected}`),
907
+ error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
908
+ )
909
+ return
910
+ }
911
+ if (images.length === 0 && line.startsWith('/permission ')) {
912
+ try {
913
+ const selected = setPermissionAction(line.slice(12).trim())
914
+ bridge.notify(`permission ${selected}`)
915
+ } catch (error: unknown) {
916
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
917
+ }
918
+ return
919
+ }
920
+ if (session === undefined) {
921
+ pendingInputs.push({ text: line, mode, images })
922
+ ensureSession()
923
+ return
924
+ }
925
+ deliverLine(line, mode, images)
926
+ }
927
+
928
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
929
+ const dispatch = (text: string, images: readonly ImageBlock[] = []): void => {
930
+ send(text, 'followup', images)
931
+ }
932
+
933
+ /**
934
+ * Submit steering: a running driver consumes the text at its next step
935
+ * boundary (the inbox delivers between steps); an idle driver just starts
936
+ * a turn, so this doubles as the busy-state submit path.
937
+ */
938
+ const steer = (text: string, images: readonly ImageBlock[] = []): void => {
939
+ send(text, 'steer', images)
940
+ }
941
+
942
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
943
+ const interrupt = (): boolean => {
944
+ if (agent === undefined || agent.status !== 'running') return false
945
+ try {
946
+ agent.cancel({ kind: 'user' })
947
+ bridge.notify('turn cancelled Ctrl+C or /quit to exit')
948
+ return true
949
+ } catch (error: unknown) {
950
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
951
+ return false
952
+ }
953
+ }
954
+
955
+ /** Select one permission preset before the first session or on the active one. */
956
+ const setPermissionAction = (id: string): string => {
957
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
958
+ throw new Error('permission presets are not mounted in this composition')
959
+ }
960
+ if (id === '') throw new Error('usage: /permission <preset>')
961
+ const selected = selectPermission(permissionPresets, session, id)
962
+ if (session === undefined) {
963
+ pendingPermission = selected
964
+ renderCurrent()
965
+ }
966
+ return selected
967
+ }
968
+
969
+ /**
970
+ * Cycle to the next permission preset (Shift+Tab). Before the first session,
971
+ * the choice remains process-local and is materialized when Harness creates
972
+ * that session; afterwards the canonical service writes durable events.
973
+ */
974
+ const cyclePermission = (): string => {
975
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
976
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
977
+ return ''
978
+ }
979
+ try {
980
+ const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
981
+ if (session === undefined && next !== '') {
982
+ pendingPermission = next
983
+ renderCurrent()
984
+ }
985
+ return next
986
+ } catch (error: unknown) {
987
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
988
+ return ''
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Apply one /model selection: takes effect from the next assembled step.
994
+ * The optional reasoning effort must be one the row advertises (the picker
995
+ * only offers those), so an unsupported value cannot reach the request
996
+ * pipeline; an absent effort restores the model's own default.
997
+ */
998
+ const selectModel = (row: ModelRow, effortId?: string): string => {
999
+ const selection = buildModelSelection(row, effortId)
1000
+ if (active === undefined) {
1001
+ // A bare launch has no session yet: keep the pick process-wide so the
1002
+ // first composed session starts from it.
1003
+ pendingSelection = selection
1004
+ } else {
1005
+ active.selection.picked = selection
1006
+ }
1007
+ // Global default (web selectModel parity): every pick is persisted as the
1008
+ // deployment default through the same agentDefaultModel service the web
1009
+ // host writes, so the choice survives restarts and other surfaces read
1010
+ // it. Save failures degrade to a notice the in-session switch already
1011
+ // took effect and must not roll back (the web contract).
1012
+ void defaultModel.saveSelection(selection).catch((error: unknown) => {
1013
+ bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
1014
+ })
1015
+ // Advisory immediate validation (web selectModel parity): run the same
1016
+ // local resolveCallConfig check the request pipeline would, so a stale
1017
+ // directory — an effort the adapter withdrew since /model loaded —
1018
+ // surfaces as a pick-time notice instead of failing the next assembled
1019
+ // step. Best-effort: an llm service without the resolver keeps the
1020
+ // existing request-boundary rejection. Called as a method (`this`-bound)
1021
+ // like resolveModelInfo in models.ts.
1022
+ const llm = ctx.get('llm')
1023
+ const resolveCallConfig = (llm as {
1024
+ resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
1025
+ } | undefined)?.resolveCallConfig
1026
+ if (llm !== undefined && typeof resolveCallConfig === 'function') {
1027
+ void Promise.resolve(resolveCallConfig.call(llm, {
1028
+ provider: selection.provider,
1029
+ model: selection.model,
1030
+ ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
1031
+ })).catch((error: unknown) => {
1032
+ bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
1033
+ })
1034
+ }
1035
+ return `${row.provider}/${row.model}`
1036
+ }
1037
+
1038
+ /** The /subagent override label, '' when delegated agents follow the current model. */
1039
+ const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
1040
+
1041
+ /** Apply one /subagent model pick; returns the override label. */
1042
+ const setSubagentModel = (row: ModelRow, effortId?: string): string => {
1043
+ subagentOverride = buildModelSelection(row, effortId)
1044
+ renderCurrent()
1045
+ return modelSelectionLabel(subagentOverride)
1046
+ }
1047
+
1048
+ /** Drop the /subagent override: delegated agents follow the current model again. */
1049
+ const clearSubagentModel = (): void => {
1050
+ subagentOverride = undefined
1051
+ renderCurrent()
1052
+ }
1053
+
1054
+ /**
1055
+ * Export the folded transcript to a markdown file (/export). The default
1056
+ * target sits beside the session's cwd so the file lands in the user's
1057
+ * workspace; an absolute or cwd-relative argument overrides it.
1058
+ */
1059
+ const exportTranscript = async (argument: string): Promise<void> => {
1060
+ if (session === undefined) {
1061
+ bridge.notify('no session yet submit a message to start', 'warning')
1062
+ return
1063
+ }
1064
+ const wanted = argument.trim()
1065
+ const sessionCwd = session.header.cwd ?? cwd
1066
+ // The default name derives from the session id, which `--session` lets the
1067
+ // user spell freely: reduce it to filename-safe characters first so the
1068
+ // default target can never escape the session cwd.
1069
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
1070
+ const target = wanted === ''
1071
+ ? join(sessionCwd, defaultName)
1072
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
1073
+ ? wanted
1074
+ : join(sessionCwd, wanted)
1075
+ const markdown = buildExportMarkdown(store.getView(), session.id)
1076
+ try {
1077
+ await writeFileAsync(target, `${markdown}\n`, 'utf8')
1078
+ bridge.notify(`exported to ${target}`)
1079
+ } catch (error: unknown) {
1080
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Rename the session (/title): a user title pins the session and stops
1086
+ * automatic generation (the service's own contract). The appended
1087
+ * `session/title` event flows back through the store into the status line.
1088
+ */
1089
+ const renameTitle = (argument: string): string => {
1090
+ const title = argument.trim()
1091
+ if (title === '') return 'usage: /title <text>'
1092
+ if (session === undefined) return 'no session yet — submit a message to start'
1093
+ const service = ctx.get('sessionTitle')
1094
+ if (service === undefined) return 'session titles are unavailable in this profile'
1095
+ try {
1096
+ service.rename(session, title)
1097
+ return `title ${title}`
1098
+ } catch (error: unknown) {
1099
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`
1100
+ }
1101
+ }
1102
+
1103
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
1104
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1105
+ const records = await sessionQuery.listSessions(signal)
1106
+ // Last-activity timestamps for sorting (codex UpdatedAt default): the
1107
+ // JSONL artifact's mtime via locate()+stat — the upstream api-proxy's own
1108
+ // cold-probe pattern. O(1) per session; backends without a location (or
1109
+ // vanished files) fall back to createdAt inside the projection.
1110
+ const updated = new Map<string, number>()
1111
+ for (const record of records) {
1112
+ const location = persistence?.locate(record.header)
1113
+ if (location === undefined) continue
1114
+ try {
1115
+ updated.set(record.header.id, (await stat(location.path)).mtimeMs)
1116
+ } catch {
1117
+ // Artifact gone or unreadable: the projection falls back to createdAt.
1118
+ }
1119
+ }
1120
+ const projected = projectSessionRows(records, options, updated)
1121
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
1122
+ // navigation/filter changes trigger a fresh, cancellable observation.
1123
+ const page = projected.slice(0, 32)
1124
+ if (page.length === 0) return projected
1125
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
1126
+ return mergeSessionTitles(projected, observations)
1127
+ }
1128
+
1129
+ /**
1130
+ * Delete one session subtree (/delete, codex semantics: subagent threads go
1131
+ * with their root). The kernel persistence seam has NO deletion API by
1132
+ * design logs accumulate "until removed externally" — so this is the
1133
+ * controlled external removal, in three phases with a hard boundary
1134
+ * between planning and touching the filesystem:
1135
+ *
1136
+ * 1. `planSessionDeletion` collects the subtree and refuses when the root
1137
+ * or ANY member is live (a live child would outlive its deleted
1138
+ * parent), ordering the plan children-first.
1139
+ * 2. Every plan node must locate to a guarded artifact directory
1140
+ * (`encodeSegment(id)`/`session.jsonl` layout). Backends without a
1141
+ * locatable artifact (SQLite) refuse the WHOLE deletion here — no
1142
+ * file has been touched yet, so a backend or layout surprise can
1143
+ * never strand a half-deleted subtree.
1144
+ * 3. Artifacts are removed children-first: only an I/O error mid-delete
1145
+ * can stop it short (reported with removed/total counts), leaving the
1146
+ * shallowest lineage intact.
1147
+ *
1148
+ * @param id - the root session id to delete.
1149
+ * @returns the outcome line for the panel/notice.
1150
+ */
1151
+ const deleteSession = async (id: string): Promise<string> => {
1152
+ if (sessionQuery === undefined) return 'session query is unavailable in this profile'
1153
+ if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
1154
+ const records = await sessionQuery.listSessions()
1155
+ const plan = planSessionDeletion(records, id)
1156
+ if (!plan.ok) return plan.reason
1157
+ // Phase 2 completes the plan before the first rm: locate and
1158
+ // layout-check every node up front, so a refusal never leaves a
1159
+ // partially removed subtree behind.
1160
+ const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
1161
+ const dirs = new Map<string, string>()
1162
+ for (const node of plan.nodes) {
1163
+ const record = byId.get(node.id)
1164
+ if (record === undefined) return `no persisted session matches "${node.id}"`
1165
+ const location = persistence?.locate(record.header)
1166
+ if (location === undefined) {
1167
+ return `session backend exposes no deletable artifact for ${node.id.slice(-12)} (deletion is unsupported on this backend)`
1168
+ }
1169
+ const dir = sessionArtifactDirectory(location.path, node.id)
1170
+ if (dir === undefined) {
1171
+ return `refusing to delete: unexpected artifact layout at ${location.path}`
1172
+ }
1173
+ dirs.set(node.id, dir)
1174
+ }
1175
+ let removed = 0
1176
+ for (const node of plan.nodes) {
1177
+ const dir = dirs.get(node.id)!
1178
+ try {
1179
+ for (const name of SESSION_ARTIFACT_NAMES) {
1180
+ await rm(join(dir, name), { force: true })
1181
+ }
1182
+ // Remove the now-empty session directory; a non-empty one stays (an
1183
+ // unexpected sibling file is never ours to delete).
1184
+ await rm(dir, { force: true, recursive: false }).catch(() => {})
1185
+ removed += 1
1186
+ } catch (error: unknown) {
1187
+ return `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
1188
+ }
1189
+ }
1190
+ return `deleted ${removed} session${removed === 1 ? '' : 's'}`
1191
+ }
1192
+
1193
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
1194
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1195
+ const snapshot = await sessionQuery.readSession(id, signal)
1196
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
1197
+ }
1198
+
1199
+ const switchModeAction = async (id: string): Promise<string> => {
1200
+ if (id === '') throw new Error('usage: /mode <preset>')
1201
+ const currentAgent = agent
1202
+ if (currentAgent === undefined) {
1203
+ const choice = pendingModeWork.then(async () => {
1204
+ const preset = await selectPreset(presets, undefined, id)
1205
+ // A resume may have won while this roster read was in flight; never
1206
+ // leak the old pending choice into a later /new session.
1207
+ if (agent === undefined) {
1208
+ pendingMode = preset.id
1209
+ renderCurrent()
1210
+ }
1211
+ return preset.id
1212
+ })
1213
+ pendingModeWork = choice.then(() => {}, () => {})
1214
+ return choice
1215
+ }
1216
+
1217
+
1218
+ // Serialize the recomposition with session activations: a /mode that
1219
+ // interleaves a switch must not rebind the shared command/skill
1220
+ // registries while the switch is composing the next agent.
1221
+ const currentActive = active
1222
+ const atEpoch = epoch
1223
+ let selected: string | undefined
1224
+ await compose(async () => {
1225
+ const preset = await selectPreset(presets, currentAgent, id)
1226
+ // A switch/quit landed while the recomposition ran: applying here
1227
+ // would write the old choice into the new session's state and rebind
1228
+ // the registries back to a disposed agent. The preset-selection log
1229
+ // entry rode the old agent's session; only the local application is
1230
+ // dropped.
1231
+ if (epoch !== atEpoch || agent !== currentAgent || active !== currentActive) {
1232
+ throw new Error('session changed while switching mode — nothing applied; retry in the active session')
1233
+ }
1234
+ if (active === undefined) throw new Error('active Agent has no session state')
1235
+ active.mode = preset.id
1236
+ commands.setAgent(currentAgent)
1237
+ skills.setAgent(currentAgent)
1238
+ selected = preset.id
1239
+ renderCurrent()
1240
+ })
1241
+ return selected!
1242
+ }
1243
+
1244
+ interface PendingSwitch { readonly target: Target; readonly label: string }
1245
+
1246
+ const activate = (nextTarget: Target): Promise<void> => {
1247
+ if (quitting) return Promise.resolve()
1248
+ // Serialized with every other composition (bare-launch creation, queued
1249
+ // switches): at most one agent is composed at a time.
1250
+ return compose(async () => {
1251
+ const previous = active
1252
+ const next = await prepare(nextTarget)
1253
+ // Quit landed while the next session was being composed: dispose the
1254
+ // half-ready agent and leave the current session untouched.
1255
+ if (quitting) {
1256
+ await next.handle.dispose().catch(() => {})
1257
+ return
1258
+ }
1259
+ active = next
1260
+ agent = next.agent
1261
+ session = next.session
1262
+ store = next.store
1263
+ mentions = next.mentions
1264
+ subagents.reset()
1265
+ pendingMode = undefined
1266
+ pendingPermission = undefined
1267
+ commands.setAgent(agent)
1268
+ skills.setAgent(agent)
1269
+ try {
1270
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1271
+ renderCurrent()
1272
+ } catch (error: unknown) {
1273
+ active = previous
1274
+ agent = previous?.agent
1275
+ session = previous?.session
1276
+ store = previous === undefined ? createTranscriptStore() : previous.store
1277
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
1278
+ if (agent !== undefined) commands.setAgent(agent)
1279
+ if (agent !== undefined) skills.setAgent(agent)
1280
+ await next.handle.dispose()
1281
+ if (!quitting) renderCurrent()
1282
+ throw error
1283
+ }
1284
+ // From here the new session is live: in-flight prepares/commands for
1285
+ // the previous agent are stale and must be aborted and ignored.
1286
+ abortPendingControllers()
1287
+ epoch += 1
1288
+ // No previous session (a bare launch switched straight into a resume):
1289
+ // nothing to flush or dispose, so just confirm the activation.
1290
+ if (previous === undefined) {
1291
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1292
+ return
1293
+ }
1294
+ let cleanupWarning: string | undefined
1295
+ try {
1296
+ await sessions.flush(previous.session)
1297
+ } catch (error: unknown) {
1298
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
1299
+ }
1300
+ try {
1301
+ await previous.handle.dispose()
1302
+ } catch (error: unknown) {
1303
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
1304
+ }
1305
+ bridge.notify(cleanupWarning === undefined
1306
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1307
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
1308
+ cleanupWarning === undefined ? 'info' : 'warning')
1309
+ })
1310
+ }
1311
+
1312
+ const switchQueue = new SessionSwitchQueue<PendingSwitch>(
1313
+ async request => { if (!quitting) await activate(request.target) },
1314
+ error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1315
+ )
1316
+
1317
+ const requestSwitch = (request: PendingSwitch): void => {
1318
+ if (session === undefined) {
1319
+ // No session yet (a bare launch using /resume before any input): activate
1320
+ // the target directly — there is no running turn to wait on and nothing
1321
+ // to flush.
1322
+ void activate(request.target).catch((error: unknown) => {
1323
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1324
+ })
1325
+ return
1326
+ }
1327
+ if (request.target.sessionId === session.id) {
1328
+ bridge.notify('that session is already active', 'warning')
1329
+ return
1330
+ }
1331
+ const outcome = switchQueue.request(agent!, request)
1332
+ if (outcome === 'queued') {
1333
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
1334
+ }
1335
+ }
1336
+
1337
+ const resolveResumeId = async (wanted: string): Promise<string> => {
1338
+ if (wanted === '') throw new Error('usage: /resume <id|prefix>')
1339
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1340
+ const records = await sessionQuery.listSessions()
1341
+ const exact = records.filter(record => record.header.id === wanted)
1342
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
1343
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
1344
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
1345
+ const matched = matches[0]!
1346
+ // Same lineage gate as the CLI --resume path and the picker.
1347
+ if (isSubagentSession(matched.header)) {
1348
+ throw new Error('subagent conversations are read-only; resume a root session')
1349
+ }
1350
+ if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
1351
+ throw new Error('that session is already live in another owner')
1352
+ }
1353
+ return matched.header.id
1354
+ }
1355
+
1356
+ const requestResume = (wanted: string): void => {
1357
+ void resolveResumeId(wanted).then(id => {
1358
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
1359
+ }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
1360
+ }
1361
+
1362
+ const createSession = (mode?: string): void => {
1363
+ // /new before any input is the first-session creation itself, not a switch.
1364
+ if (session === undefined) {
1365
+ ensureSession(mode)
1366
+ return
1367
+ }
1368
+ const nextCwd = session.header.cwd ?? cwd
1369
+ const id = `session-${randomUUID()}`
1370
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
1371
+ }
1372
+
1373
+ const reviewChanges = (argument: string): void => {
1374
+ const currentAgent = agent
1375
+ if (currentAgent === undefined) {
1376
+ bridge.notify('no session yet - submit a message to start', 'warning')
1377
+ return
1378
+ }
1379
+ // The diff loads from the CALLING session's cwd; capture that
1380
+ // workspace and this turn's identity so a switch mid-load can neither
1381
+ // flip the new session read-only nor send the old workspace's review
1382
+ // into it. The controller rides pendingControllers, so a switch/quit
1383
+ // kills the git subprocess itself instead of only ignoring its result.
1384
+ const atEpoch = epoch
1385
+ const reviewCwd = session?.header.cwd ?? cwd
1386
+ const controller = new AbortController()
1387
+ pendingControllers.add(controller)
1388
+ const finish = (): void => {
1389
+ pendingControllers.delete(controller)
1390
+ }
1391
+ void loadGitDiff(reviewCwd, argument, controller.signal).then(({ title, files }) => {
1392
+ finish()
1393
+ if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
1394
+ try {
1395
+ setPermissionAction('read-only')
1396
+ } catch (error: unknown) {
1397
+ bridge.notify(`review unavailable: ${error instanceof Error ? error.message : String(error)}`, 'error')
1398
+ return
1399
+ }
1400
+ send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title), 'followup')
1401
+ bridge.notify('review started under read-only permissions')
1402
+ }, (error: unknown) => {
1403
+ finish()
1404
+ if (controller.signal.aborted || epoch !== atEpoch) return
1405
+ bridge.notify(`review failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1406
+ })
1407
+ }
1408
+
1409
+ const forkSession = (argument: string): void => {
1410
+ if (session === undefined || active === undefined) {
1411
+ bridge.notify('no session yet - submit a message to start', 'warning')
1412
+ return
1413
+ }
1414
+ try {
1415
+ const text = argument.trim()
1416
+ const atSeq = text === '' ? undefined : Number(text)
1417
+ if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
1418
+ throw new Error('usage: /fork [event-seq]')
1419
+ }
1420
+ const seed = selectForkSeed(session.events, atSeq)
1421
+ const id = `session-${randomUUID()}`
1422
+ requestSwitch({
1423
+ target: {
1424
+ sessionId: id,
1425
+ resume: false,
1426
+ mode: active.mode,
1427
+ cwd: session.header.cwd ?? cwd,
1428
+ seed: seed.events,
1429
+ parentSession: session.id,
1430
+ seedLength: seed.events.length,
1431
+ },
1432
+ label: id.slice(-12),
1433
+ })
1434
+ } catch (error: unknown) {
1435
+ bridge.notify(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1436
+ }
1437
+ }
1438
+
1439
+ const switchSession = (row: SessionRow): void => {
1440
+ if (!row.resumable) {
1441
+ bridge.notify('subagent conversations are read-only', 'warning')
1442
+ return
1443
+ }
1444
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
1445
+ }
1446
+
1447
+ const cancelSessionSwitch = (): boolean => {
1448
+ return switchQueue.cancel()
1449
+ }
1450
+
1451
+ const appElement = (): ReturnType<typeof createElement> => {
1452
+ // A bare launch mounts with pending/default model, mode, and permission
1453
+ // facts until the first input composes the real session. These choices stay
1454
+ // process-local and create no durable state before that composition.
1455
+ const sessionCwd = session?.header.cwd ?? cwd
1456
+ const currentView = store.getView()
1457
+ const defaults = currentDefaults()
1458
+ const model = currentView.model !== ''
1459
+ ? currentView.model
1460
+ : pendingSelection !== undefined
1461
+ ? `${pendingSelection.provider}/${pendingSelection.model}`
1462
+ : `${defaults.provider}/${defaults.model}`
1463
+ const effort = resolveEffectiveSelection(
1464
+ active?.selection.picked ?? pendingSelection,
1465
+ session?.requestHeader()?.config,
1466
+ defaults,
1467
+ ).reasoningEffort
1468
+ const permission = permissionPresets === undefined
1469
+ ? currentView.permission
1470
+ : effectivePermission(permissionPresets, session, pendingPermission)
1471
+ return createElement(App, {
1472
+ key: session?.id ?? 'pending',
1473
+ store,
1474
+ approval,
1475
+ questions,
1476
+ subagents,
1477
+ commands,
1478
+ skills,
1479
+ model,
1480
+ effort,
1481
+ cwd: basename(sessionCwd),
1482
+ workspaceRoot: sessionCwd,
1483
+ branch: gitBranch(sessionCwd),
1484
+ sessionId: session === undefined ? '' : session.id.slice(-8),
1485
+ resumed: active?.resumed ?? false,
1486
+ mode: active?.mode ?? pendingMode ?? presets.defaultId,
1487
+ permission,
1488
+ dispatch,
1489
+ steer,
1490
+ interrupt,
1491
+ quit,
1492
+ loadModels: () => loadModelDirectory(ctx),
1493
+ loadModelProviders: () => loadProviderSettings(ctx),
1494
+ subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1495
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1496
+ saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
1497
+ unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1498
+ removeModelProvider: target => removeProviderSettings(ctx, target),
1499
+ loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
1500
+ subscribeProviderAuthorizations: listener => subscribeProviderAuthorizations(ctx, listener),
1501
+ beginProviderAuthorization: (row, method, interaction, signal) => (
1502
+ beginProviderAuthorization(ctx, row, method, interaction, signal)
1503
+ ),
1504
+ cancelProviderAuthorization: row => cancelProviderAuthorization(ctx, row.key),
1505
+ logoutProviderAuthorization: row => logoutProviderAuthorization(ctx, row),
1506
+ openAuthorizationUrl,
1507
+ copyTextValue: copyText,
1508
+ loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
1509
+ inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
1510
+ prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
1511
+ cyclePermission,
1512
+ setPermission: setPermissionAction,
1513
+ selectModel,
1514
+ subagentModel: subagentModelLabel(),
1515
+ setSubagentModel,
1516
+ clearSubagentModel,
1517
+ deleteSession,
1518
+ exportTranscript,
1519
+ renameTitle,
1520
+ copyLastResponse,
1521
+ loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
1522
+ reviewChanges,
1523
+ loadPresets: () => presets.list(),
1524
+ switchMode: switchModeAction,
1525
+ loadPermissions: () => permissionPresets === undefined
1526
+ ? Promise.reject(new Error('permission presets are not mounted in this composition'))
1527
+ : Promise.resolve(listPermissionRows(permissionPresets)),
1528
+ createSession,
1529
+ forkSession,
1530
+ loadSessions,
1531
+ loadSessionTranscript,
1532
+ loadSubagents: () => {
1533
+ const current = session
1534
+ if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
1535
+ return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
1536
+ .then(rows => rows.filter(row => row.parent === current.id && row.subagent))
1537
+ },
1538
+ switchSession,
1539
+ cancelSessionSwitch,
1540
+ loadPlugins: () => listPluginRows(ctx),
1541
+ loadJobs: () => listJobs(ctx, active?.agent),
1542
+ statusline: statuslineItems,
1543
+ saveStatusline,
1544
+ applyEditorKeys,
1545
+ saveTheme,
1546
+ history: inputHistory,
1547
+ recordHistory,
1548
+ cancelQueued,
1549
+ onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
1550
+ })
1551
+ }
1552
+
1553
+ const renderCurrent = (): void => {
1554
+ mountRef.current?.rerender(appElement())
1555
+ }
1556
+
1557
+ mountRef.current = io.mount(appElement())
1558
+
1559
+ // Startup prompt/images use the same durable delivery path as composer
1560
+ // submissions. Image bytes are committed before the user/message event.
1561
+ if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
1562
+ if ((startup.images?.length ?? 0) > 0) {
1563
+ bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
1564
+ }
1565
+ void saveImagePaths(startup.images ?? [], ctx.get('attachments')).then(
1566
+ images => {
1567
+ if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
1568
+ send(startup.prompt ?? '', 'followup', images)
1569
+ },
1570
+ (error: unknown) => bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1571
+ )
1572
+ }
1573
+
1574
+ async function copyLastResponse(): Promise<string> {
1575
+ const text = latestAssistantText(store.getView())
1576
+ if (text === undefined) return 'nothing to copy yet'
1577
+ await copyText(text)
1578
+ return 'copied latest response'
1579
+ }
1580
+
1581
+ // A corrupt statusline config must not vanish silently: surface it once
1582
+ // the notice channel is live, after the first frame settles.
1583
+ if (statuslineWarning !== undefined) {
1584
+ setTimeout(() => {
1585
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
1586
+ }, 50)
1587
+ }
1588
+ // Same one-shot surface for a corrupt theme file (dark fallback stays live).
1589
+ if (themeWarning !== undefined) {
1590
+ setTimeout(() => {
1591
+ bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
1592
+ }, 50)
1593
+ }
1594
+
1595
+ // One-shot VS Code Ctrl+R hint: resolveEditorKeysStartupHint checks the
1596
+ // marker file and the live keybindings config; surfacing waits for the
1597
+ // notice channel like the other startup warnings. A failed probe stays
1598
+ // silent — the hint is cosmetic and /vscode-keys remains discoverable.
1599
+ void resolveEditorKeysStartupHint(editorKeysEnv).then(hint => {
1600
+ if (hint === undefined) return
1601
+ setTimeout(() => {
1602
+ bridge.notify(hint)
1603
+ }, 50)
1604
+ }, () => {})
1605
+ }
1606
+
1607
+ /**
1608
+ * Mount the interactive terminal driver.
1609
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
1610
+ * @param config - validated startup config resolved from the tuiStartup provider.
1611
+ */
1612
+ export function apply(ctx: Context, config: Config): void {
1613
+ // The CLI validated --theme at parse time; the loose config schema falls
1614
+ // back to dark for anything unexpected.
1615
+ const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
1616
+ const input = {
1617
+ ...(theme === undefined ? {} : { theme }),
1618
+ ...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
1619
+ ...(config.startup.images === undefined ? {} : { images: config.startup.images }),
1620
+ }
1621
+ const startup: TuiStartup =
1622
+ config.startup.kind === 'resume' && config.startup.sessionId !== undefined
1623
+ ? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
1624
+ : config.startup.kind === 'latest'
1625
+ ? { kind: 'latest', ...input }
1626
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
1627
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
1628
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
1629
+ // Read through the global service store, not the property proxy: appExit is
1630
+ // an optional host value, never an injected dependency.
1631
+ const exit = ctx.get('appExit')
1632
+ if (exit === undefined) {
1633
+ throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
1634
+ }
1635
+ const io: TuiIo = { mount: internals.mount, exit }
1636
+ void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
1637
+ }