dsh-code 1.0.5 → 1.0.7

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