@toddzheng024/dscode-bundle 0.7.12 → 0.7.14

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