dsh-code 0.9.0 → 1.0.0

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