dsh-code 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/index.mjs +5115 -4390
  4. package/lib/types/app.d.ts +18 -51
  5. package/lib/types/completion.d.ts +29 -0
  6. package/lib/types/composer.d.ts +150 -0
  7. package/lib/types/git-workflow.d.ts +6 -0
  8. package/lib/types/index.d.ts +6 -188
  9. package/lib/types/locales/en.d.ts +16 -4
  10. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  11. package/lib/types/panels/completion-panel.d.ts +13 -0
  12. package/lib/types/panels/interaction-bars.d.ts +38 -0
  13. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +16 -37
  14. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  15. package/lib/types/panels/model-panels.d.ts +86 -0
  16. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  17. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +1 -1
  18. package/lib/types/provider-settings.d.ts +11 -0
  19. package/lib/types/render/inspector.d.ts +8 -0
  20. package/lib/types/render/text.d.ts +4 -0
  21. package/lib/types/runner/harness-gate.d.ts +83 -0
  22. package/lib/types/runner/input-history.d.ts +31 -0
  23. package/lib/types/runner/mode-cycle.d.ts +44 -0
  24. package/lib/types/runner/preferences.d.ts +40 -0
  25. package/lib/types/runner/quit.d.ts +27 -0
  26. package/lib/types/runner/search-rows.d.ts +38 -0
  27. package/lib/types/runner/session-io.d.ts +46 -0
  28. package/lib/types/runner/session-target.d.ts +42 -0
  29. package/lib/types/runner/startup-config.d.ts +33 -0
  30. package/lib/types/runner/submissions.d.ts +87 -0
  31. package/lib/types/session/attach.d.ts +39 -0
  32. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  33. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  34. package/lib/types/settings-file.d.ts +10 -0
  35. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  36. package/lib/types/ui/panel-gap.d.ts +6 -0
  37. package/lib/types/ui/query-editor.d.ts +10 -0
  38. package/lib/types/ui/styled-rows.d.ts +8 -0
  39. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  40. package/lib/types/ui/ui-contract.d.ts +12 -0
  41. package/lib/types/ui/use-frames.d.ts +6 -0
  42. package/lib/types/ui/use-stable-input.d.ts +7 -0
  43. package/package.json +1 -1
  44. package/src/app.ts +543 -4066
  45. package/src/completion.ts +117 -0
  46. package/src/composer.ts +1956 -0
  47. package/src/git-workflow.ts +18 -0
  48. package/src/index.ts +113 -633
  49. package/src/internals.ts +1 -1
  50. package/src/locales/en.ts +16 -4
  51. package/src/locales/zh.ts +16 -4
  52. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +25 -6
  53. package/src/panels/completion-panel.ts +79 -0
  54. package/src/panels/interaction-bars.ts +567 -0
  55. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +84 -65
  56. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  57. package/src/panels/model-panels.ts +1021 -0
  58. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  59. package/src/{update-panel.ts → panels/update-panel.ts} +6 -6
  60. package/src/provider-settings.ts +38 -0
  61. package/src/render/inspector.ts +23 -0
  62. package/src/render/text.ts +8 -0
  63. package/src/runner/harness-gate.ts +168 -0
  64. package/src/runner/input-history.ts +77 -0
  65. package/src/runner/mode-cycle.ts +49 -0
  66. package/src/runner/preferences.ts +67 -0
  67. package/src/runner/quit.ts +53 -0
  68. package/src/runner/search-rows.ts +55 -0
  69. package/src/runner/session-io.ts +206 -0
  70. package/src/runner/session-target.ts +81 -0
  71. package/src/runner/startup-config.ts +54 -0
  72. package/src/runner/submissions.ts +157 -0
  73. package/src/session/attach.ts +87 -0
  74. package/src/{session-directory.ts → session/session-directory.ts} +1 -1
  75. package/src/{store.ts → session/store.ts} +1 -1
  76. package/src/{subagents.ts → session/subagents.ts} +12 -1
  77. package/src/settings-file.ts +19 -1
  78. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  79. package/src/ui/panel-gap.ts +9 -0
  80. package/src/ui/query-editor.ts +16 -0
  81. package/src/ui/styled-rows.ts +124 -0
  82. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  83. package/src/ui/ui-contract.ts +10 -0
  84. package/src/ui/use-frames.ts +23 -0
  85. package/src/ui/use-stable-input.ts +17 -0
  86. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
  87. /package/lib/types/{history.d.ts → session/history.d.ts} +0 -0
  88. /package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +0 -0
  89. /package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +0 -0
  90. /package/src/{fork.ts → session/fork.ts} +0 -0
  91. /package/src/{history.ts → session/history.ts} +0 -0
  92. /package/src/{session-switch.ts → session/session-switch.ts} +0 -0
package/src/index.ts CHANGED
@@ -10,29 +10,49 @@
10
10
  */
11
11
 
12
12
  import { randomUUID } from 'node:crypto'
13
- import { readFileSync } from 'node:fs'
14
13
  import { homedir } from 'node:os'
15
- import { appendFile as appendFileAsync, mkdir, readdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
16
- import { basename, dirname, join } from 'node:path'
14
+ import { writeFile as writeFileAsync } from 'node:fs/promises'
15
+ import { basename, join } from 'node:path'
17
16
  import { createElement } from 'react'
18
17
  import type { Context } from '@deepseek-ai/cordis'
19
18
  import z from '@deepseek-ai/schemastery'
20
19
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
- import type { Agent, AgentHandle, AgentStatus, Inbox, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
20
+ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
21
  import type {} from '@deepseek-ai/dsh-agent-default-model'
23
22
  import type {} from '@deepseek-ai/dsh-attachment'
24
- import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/dsh-llm'
23
+ import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
25
24
  import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
26
- import { SessionId, SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
25
+ import { SessionId, SessionLogOffset, type Session, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
27
26
  import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
28
- import { SessionAlreadyOwnedError, type SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
29
27
  // Type-only: carries the ctx.sessionTitle service merge for /title.
30
28
  import type {} from '@deepseek-ai/dsh-session-title'
31
29
  // Empty type imports carry the loader Context merge for the settlement await
32
30
  // and the cmdline Context merge for the appExit host value.
33
31
  import type {} from '@deepseek-ai/cordis-plugin-loader'
34
32
  import type {} from '@deepseek-ai/dsh-cmdline'
35
- import { App, type NoticeTone, type QueueMutation } from './app.ts'
33
+ import { App } from './app.ts'
34
+ import type { NoticeTone, QueueMutation } from './ui/ui-contract.ts'
35
+ import { planCycleDecision } from './runner/mode-cycle.ts'
36
+ export { planCycleDecision, type ModeCycleDecision } from './runner/mode-cycle.ts'
37
+ import { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
38
+ export { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
39
+ import { exportSessionIdSuffix, resolveTarget, type Target } from './runner/session-target.ts'
40
+ export { exportSessionIdSuffix, resolveTarget } from './runner/session-target.ts'
41
+ import {
42
+ applyQueueMutation,
43
+ cancelPreservingQueue,
44
+ StartupInputGate,
45
+ submissionBelongsToSession,
46
+ } from './runner/submissions.ts'
47
+ export {
48
+ applyQueueMutation,
49
+ cancelPreservingQueue,
50
+ queueEditContent,
51
+ StartupInputGate,
52
+ submissionBelongsToSession,
53
+ type QueuedSubmission,
54
+ type QueueMutationOutcome,
55
+ } from './runner/submissions.ts'
36
56
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
37
57
  import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
38
58
  import { internals, type TuiMount } from './internals.ts'
@@ -44,6 +64,7 @@ import {
44
64
  removeProviderSettings,
45
65
  saveProviderCredential,
46
66
  saveProviderConfiguration,
67
+ enableProviderSubscription,
47
68
  subscribeProviderSettings,
48
69
  unsetProviderCredential,
49
70
  } from './provider-settings.ts'
@@ -52,10 +73,10 @@ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
52
73
  // Type-only import merges the settings Events declarations ('settings/updated',
53
74
  // 'settings/document-updated') into this program's Cordis bus typing.
54
75
  import type {} from '@deepseek-ai/dsh-settings'
55
- import { createTranscriptStore, type TranscriptStore } from './store.ts'
56
- import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
76
+ import { createTranscriptStore, type TranscriptStore } from './session/store.ts'
77
+ import { createSubagentFeed, subagentCatalogSeed, type SubagentFeedView } from './session/subagents.ts'
78
+ export { subagentCatalogSeed } from './session/subagents.ts'
57
79
  import { parseStatuslineItems } from './render/status.ts'
58
- import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
59
80
  import { watchSkills, type SkillsView } from './skills.ts'
60
81
  import { toolArgumentsPreview } from './render/tool-preview.ts'
61
82
  import { buildExportMarkdown } from './render/export.ts'
@@ -70,7 +91,8 @@ import {
70
91
  openAuthorizationUrl,
71
92
  subscribeProviderAuthorizations,
72
93
  } from './authorization.ts'
73
- import { selectForkSeed } from './fork.ts'
94
+ import { selectForkSeed } from './session/fork.ts'
95
+ import { gitBranch } from './git-workflow.ts'
74
96
  import {
75
97
  buildReviewPrompt,
76
98
  listReviewBranches,
@@ -81,7 +103,7 @@ import {
81
103
  type ReviewSelection,
82
104
  } from './git-workflow.ts'
83
105
  import type { TuiStartup } from './startup.ts'
84
- import { SessionSwitchQueue } from './session-switch.ts'
106
+ import { SessionSwitchQueue } from './session/session-switch.ts'
85
107
  import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
86
108
  import {
87
109
  applyPendingPermission,
@@ -96,25 +118,19 @@ import { parseAnimationsPref } from './render/animations.ts'
96
118
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
97
119
  import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
98
120
  import {
99
- acquireSessionDeletionLeases,
100
121
  isSubagentSession,
101
- matchSessionId,
102
- mergeSessionTitles,
103
- newestRootForCwd,
104
- sessionRowMatchesQuery,
105
- isSessionArtifactName,
106
- jsonlSessionRoot,
107
- planSessionDeletion,
108
- projectSessionRows,
109
- releaseSessionDeletionLeases,
110
- sessionArtifactDirectory,
111
- sessionDirectoryFor,
112
- type SessionDirectoryOptions,
113
122
  type SessionQueryService,
114
123
  type SessionRow,
115
- } from './session-directory.ts'
116
- import type { JobRow, SearchRow } from './kernel-panels.ts'
117
- import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
124
+ } from './session/session-directory.ts'
125
+ import type { JobRow } from './panels/kernel-panels.ts'
126
+ import { searchHitToRow, type SearchRow } from './runner/search-rows.ts'
127
+ export { searchHitToRow } from './runner/search-rows.ts'
128
+ import { createUserSettingsPersistence } from './settings-file.ts'
129
+ import { preferencePath, readPreference, savePreference } from './runner/preferences.ts'
130
+ import { createInputHistory } from './runner/input-history.ts'
131
+ import { createSessionIo } from './runner/session-io.ts'
132
+ import { EXPECTED_HARNESS_VERSION, probeRunningHarness, requireHarnessVersion } from './runner/harness-gate.ts'
133
+ import { resolveStartupConfig } from './runner/startup-config.ts'
118
134
  import { turnUsages, type UsageView } from './render/usage.ts'
119
135
  // Type-only import: merges the projection registry into the Context type so
120
136
  // `ctx.get('sessionProjections')` is typed (the service itself is mounted by
@@ -184,373 +200,16 @@ function listJobs(ctx: Context, caller: Agent | undefined): readonly JobRow[] {
184
200
  }
185
201
  }
186
202
 
187
- /**
188
- * Read one user-level settings file as a plain object. The callers all treat a
189
- * missing file as "unset" and a corrupt one as "warn and fall back", so this
190
- * helper owns the one distinction they share: readable JSON that is not an
191
- * object is corruption, not an absent preference, and must not surface as a
192
- * cryptic property access on `null`.
193
- * @param path - absolute path of the settings file.
194
- * @returns the parsed object; the caller narrows each field itself.
195
- */
196
- function readSettingsObject(path: string): Record<string, unknown> {
197
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
198
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
199
- throw new Error(`${basename(path)} must contain a JSON object`)
200
- }
201
- return parsed as Record<string, unknown>
202
- }
203
-
204
- /**
205
- * Resolve the working directory's git branch for the status line.
206
- * @param cwd - the session's working directory.
207
- * @returns the branch name, or '' outside a repository or on a detached HEAD.
208
- */
209
- function gitBranch(cwd: string): string {
210
- try {
211
- const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
212
- return ref?.[1] ?? ''
213
- } catch {
214
- // Only the single HEAD read is attempted, so the sole reachable failure is
215
- // a missing repository (or unreadable HEAD file): the branch group drops out.
216
- return ''
217
- }
218
- }
219
-
220
- /** The session identity this invocation will run, plus whether it is resumed. */
221
- interface Target {
222
- sessionId: string
223
- resume: boolean
224
- mode?: string
225
- cwd?: string
226
- seed?: readonly SessionEvent[]
227
- parentSession?: SessionId
228
- /** Marks the session as a subagent conversation in the durable header. */
229
- origin?: 'subagent'
230
- seedLength?: number
231
- }
232
-
233
- /**
234
- * Reduce a session id to a filename-safe /export default-name suffix. Session
235
- * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
236
- * user text: path separators must never leak into the default export filename
237
- * (which would escape the session cwd).
238
- * @param id - the session id.
239
- * @returns at most the last 8 filename-safe characters.
240
- */
241
- export function exportSessionIdSuffix(id: string): string {
242
- return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
243
- }
244
-
245
- /** One ordered step of the terminal quit cleanup. */
246
- export interface QuitCleanupStep {
247
- /** Step label used in diagnostics and tests. */
248
- readonly name: string
249
- /** The step's async work; a rejection is contained by the sequence. */
250
- readonly run: () => Promise<void>
251
- }
252
-
253
- /**
254
- * Run the ordered quit cleanup, then request exit. Every step rejection is
255
- * contained (reported through `onError`) so a failed flush or dispose never
256
- * skips the remaining cleanup; the exit request is always reached exactly
257
- * once.
258
- * @param steps - the cleanup steps in dependency order (settle the visible
259
- * session, await the final in-flight composition, await durable recall).
260
- * @param exit - the terminal exit request (code 0).
261
- * @param onError - optional failure sink; called once per failing step and
262
- * itself contained, so a throwing sink cannot abort the sequence.
263
- * @returns the names of the steps that started, in order (for tests).
264
- */
265
- export async function runQuitSequence(
266
- steps: readonly QuitCleanupStep[],
267
- exit: (code: number) => void,
268
- onError?: (name: string, error: unknown) => void,
269
- ): Promise<readonly string[]> {
270
- const started: string[] = []
271
- for (const step of steps) {
272
- started.push(step.name)
273
- try {
274
- await step.run()
275
- } catch (error) {
276
- try {
277
- onError?.(step.name, error)
278
- } catch {
279
- // The failure sink must never abort the cleanup sequence.
280
- }
281
- }
282
- }
283
- try {
284
- exit(0)
285
- } catch {
286
- // The exit request itself must not become an unhandled rejection.
287
- }
288
- return started
289
- }
290
-
291
- /** One composer submission waiting behind the startup delivery. */
292
- export interface QueuedSubmission {
293
- readonly text: string
294
- /** `steer` inserts into the running turn; `followup` waits for the next one. */
295
- readonly mode: 'followup' | 'steer'
296
- readonly images: readonly ContentBlock[]
297
- }
298
-
299
- /** What one requested queue mutation did; the runner maps it to one notice. */
300
- export type QueueMutationOutcome =
301
- | 'removed'
302
- | 'edited'
303
- | 'steered'
304
- | 'unavailable'
305
- | 'empty'
306
- | 'steerUnavailable'
307
-
308
- /**
309
- * Replace one queued message's text while keeping its attachments. A queue
310
- * edit rewrites what the user typed, not what they attached: image and file
311
- * blocks ride through in delivery order (text first, then attachments, the
312
- * shape {@link deliverLine} submits). Dropping them here would silently strip
313
- * an attachment the user already confirmed, so this is the edit's single
314
- * definition and the panel's read-only marker only mirrors it.
315
- */
316
- export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
317
- const attachments = content.filter(block => block.type !== 'text')
318
- return [{ type: 'text', text }, ...attachments]
319
- }
320
203
 
321
- /**
322
- * Apply one terminal queue mutation to the live inbox. The decision and the
323
- * inbox change are pure over the supplied handles so every branch is testable
324
- * without an agent; steering itself is injected because it wakes the driver
325
- * rather than mutating the inbox. The durable inbox splices remain the UI's
326
- * single source of truth — this helper never reports a state the inbox did not
327
- * actually reach.
328
- * @param inbox - the live agent inbox (pending lists plus its mutators).
329
- * @param status - the agent's lifecycle status; steering needs `running`.
330
- * @param messageId - identity of the queued message to mutate.
331
- * @param action - the requested mutation.
332
- * @param steer - submits the removed message as next-step steering.
333
- * @returns the outcome the caller reports.
334
- */
335
- export function applyQueueMutation(
336
- inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
337
- status: AgentStatus,
338
- messageId: string,
339
- action: QueueMutation,
340
- steer: (message: UserMessage) => void,
341
- ): QueueMutationOutcome {
342
- const id = MessageId(messageId)
343
- const message = inbox.nextTurn.find(candidate => candidate.id === id)
344
- if (message === undefined) return 'unavailable'
345
- switch (action.kind) {
346
- case 'remove':
347
- return inbox.remove(id) ? 'removed' : 'unavailable'
348
- case 'edit':
349
- if (action.text.trim() === '') return 'empty'
350
- inbox.replace(id, createUserMessage({
351
- content: queueEditContent(message.content, action.text),
352
- source: message.source,
353
- }))
354
- return 'edited'
355
- case 'steer':
356
- if (status !== 'running') return 'steerUnavailable'
357
- // Steer promotes the message out of next-turn, so a failing submit must
358
- // put it back: the row the user was looking at never just disappears.
359
- if (!inbox.remove(id)) return 'unavailable'
360
- try {
361
- steer(message)
362
- } catch (error: unknown) {
363
- inbox.append('next-turn', message)
364
- throw error
365
- }
366
- return 'steered'
367
- }
368
- }
369
204
 
370
- /**
371
- * Cancel the active turn while keeping the next-turn queue, then wake the
372
- * driver again so the preserved messages actually run. `cancel` clears
373
- * pending work by default and never wakes the driver on its own, so the queue
374
- * is captured first and re-submitted afterwards: a waking submission latches
375
- * the wake while the aborted activity converges to idle, which is what turns
376
- * "preserved" into "sent next" instead of "parked forever". Next-step
377
- * steering is deliberately dropped — it belonged to the cancelled turn.
378
- * @param agent - the live agent handle.
379
- * @returns how many queued messages were preserved across the abort.
380
- */
381
- export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
382
- const queued = [...agent.inbox.nextTurn]
383
- agent.cancel({ kind: 'user' })
384
- for (const message of queued) agent.followup(message)
385
- return queued.length
386
- }
387
205
 
388
- /**
389
- * Whether a tagged submission still belongs to the active session. Attachment
390
- * prepares resolve on the microtask timeline, while a queued session switch
391
- * remounts the app asynchronously — the composing instance's unmount cleanup
392
- * runs too late to abort, so the delivery itself carries the composing
393
- * session's full id and the runner drops it here when the world moved on.
394
- * An untagged (synchronous) or pending-session ('') submission always passes.
395
- */
396
- export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
397
- return origin === undefined || origin === '' || origin === activeSessionId
398
- }
399
206
 
400
- /**
401
- * Root-log catalog facts a resumed session must replay into the subagent
402
- * feed: constructor seeds never fire on the live bus, so without this the
403
- * children of a resumed session vanish behind a restart. The empty-child
404
- * placeholder row (childId '') is a placeholder, not a child, and stays out.
405
- */
406
- export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
407
- return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
408
- event.type === 'subagent/catalog' && event.data.childId !== '')
409
- }
410
207
 
411
- /**
412
- * Map one cross-session full-text hit onto the /search panel's row (pure).
413
- * Labels fall back to the short id form — the engine's hit carries the
414
- * strongest matching event, not the title observation.
415
- */
416
- export function searchHitToRow(hit: {
417
- header: SessionHeader
418
- bestMatch: { snippet: string; time: number }
419
- }): SearchRow {
420
- const subagent = hit.header.origin === 'subagent'
421
- const cwd = hit.header.cwd ?? ''
422
- // Session cwds may arrive in either separator style regardless of the
423
- // observing host (a workspace synced from Windows), so split on both.
424
- const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
425
- const preset = hit.header.agentPreset ?? ''
426
- const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
427
- return {
428
- id: hit.header.id,
429
- label: hit.header.id.slice(-12),
430
- detail: [workspace, preset].filter(part => part !== '').join(' · '),
431
- snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
432
- updatedAt: hit.bestMatch.time,
433
- subagent,
434
- resumable: !subagent,
435
- }
436
- }
437
208
 
438
- /** One Shift+Tab station decision for the mode cycle. */
439
- export type ModeCycleDecision =
440
- | { readonly kind: 'permission'; readonly preset: string }
441
- | { readonly kind: 'plan-on' }
442
- | { readonly kind: 'plan-off'; readonly preset: string }
443
209
 
444
- /**
445
- * Decide the next Shift+Tab station. The cycle keeps the preset table's
446
- * own order (most restrictive first) and inserts ONE plan station between
447
- * the most restrictive preset and the wrap target: with the shipped three
448
- * presets the user sees workspace-write → danger-full-access → read-only
449
- * → plan → workspace-write. Plan IS the most restrictive preset plus the
450
- * plan prompt layer — entering it switches nothing (the cycle is already
451
- * parked on read-only), and leaving it lands on the next preset after the
452
- * most restrictive one. Without the /plan command the cycle is exactly the
453
- * preset table.
454
- *
455
- * `planIntent` covers the committed fold's commit lag: upstream queues a
456
- * plan switch during an open turn (and the command pipeline is async even
457
- * idle), so the durable plan/mode event lands AFTER the press that chose
458
- * it. While an intent from an earlier press is in flight it — not the
459
- * stale committed fold — decides the station, so repeated presses advance
460
- * the cycle instead of re-issuing the same plan transition (the stuck
461
- * plan-on/plan-off toggle). Undefined falls back to the committed fold.
462
- */
463
- export function planCycleDecision(input: {
464
- readonly names: readonly string[]
465
- readonly current: string
466
- readonly inPlan: boolean
467
- readonly planAvailable: boolean
468
- readonly planIntent?: boolean
469
- }): ModeCycleDecision | undefined {
470
- const names = input.names
471
- if (names.length === 0) return undefined
472
- const first = names[0]
473
- if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
474
- const at = names.indexOf(input.current)
475
- if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
476
- return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
477
- }
478
210
 
479
- /**
480
- * Order-preserving gate for composer input while the startup prompt/images
481
- * are still preparing. Anything submitted before the startup delivery settles
482
- * queues and flushes afterwards in submit order, so the initial request can
483
- * never be overtaken by typing that raced a slow image preparation. The flush
484
- * also runs when the startup delivery fails: user input is never stranded.
485
- */
486
- export class StartupInputGate {
487
- private readonly queued: QueuedSubmission[] = []
488
- private pending = false
489
- constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
490
-
491
- /** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
492
- submit(submission: QueuedSubmission): void {
493
- if (this.pending) this.queued.push(submission)
494
- else this.deliver(submission)
495
- }
496
211
 
497
- /**
498
- * Run the startup delivery — the callback receives the direct-delivery sink
499
- * for the startup prompt itself — then flush everything that queued behind
500
- * it, in order, even when the callback rejects.
501
- */
502
- async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
503
- this.pending = true
504
- try {
505
- await startup(submission => this.deliver(submission))
506
- } finally {
507
- this.pending = false
508
- const queued = this.queued.splice(0)
509
- for (const submission of queued) this.deliver(submission)
510
- }
511
- }
512
- }
513
212
 
514
- /**
515
- * Resolve the invocation's target session against the persisted headers.
516
- * @param startup - the parsed startup flags.
517
- * @param persistence - the persistence service; required for resume/latest.
518
- * @param cwd - the working directory `--continue` filters by.
519
- * @returns the target identity.
520
- * @throws with a user-facing message when the flags name nothing resolvable.
521
- */
522
- export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
523
- if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
524
- if (startup.kind === 'named') {
525
- // The id must not exist yet: reject before any Agent composition when the
526
- // backend can tell us (a live collision is still caught by the session
527
- // store at create time).
528
- if (persistence !== undefined) {
529
- const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
530
- if (headers.some(header => header.id === startup.sessionId)) {
531
- throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
532
- }
533
- }
534
- return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
535
- }
536
- if (persistence === undefined) {
537
- throw new Error('cannot resolve the requested session: session persistence is not configured')
538
- }
539
- const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
540
- if (startup.kind === 'resume') {
541
- const matched = matchSessionId(headers, startup.sessionId)
542
- // Subagent conversations are read-only everywhere else; the CLI must not
543
- // be a back door into appending root turns to a child's durable log.
544
- if (isSubagentSession(matched)) {
545
- throw new Error('subagent conversations are read-only; resume a root session')
546
- }
547
- return { sessionId: matched.id, resume: true }
548
- }
549
- // --continue: the newest persisted ROOT session whose header pins this cwd.
550
- const newest = newestRootForCwd(headers, cwd)
551
- if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
552
- return { sessionId: newest.id, resume: true }
553
- }
554
213
 
555
214
  /**
556
215
  * Resolve a bounded command preview for one pending approval: the request
@@ -944,17 +603,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
944
603
  // /statusline persistence: one user-level JSON file under the DSH home.
945
604
  // Missing file means defaults; a corrupt file degrades to defaults with a
946
605
  // surfaced warning (the customization is user-authored, never silent).
947
- const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
948
- let statuslineWarning: string | undefined
949
- let statuslineItems: readonly string[] = []
950
- try {
951
- statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
952
- } catch (error) {
953
- statuslineItems = parseStatuslineItems(undefined)
954
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
955
- statuslineWarning = error instanceof Error ? error.message : String(error)
956
- }
957
- }
606
+ const statuslinePath = preferencePath('statusline.json')
607
+ const statuslineRead = readPreference(statuslinePath, 'items', parseStatuslineItems)
608
+ const statuslineWarning: string | undefined = statuslineRead.warning
609
+ let statuslineItems: readonly string[] = statuslineRead.value ?? parseStatuslineItems(undefined)
958
610
  // Serialized, crash-atomic writes for the user-level JSON files: the chain
959
611
  // orders rapid consecutive saves (the LAST snapshot wins on disk), each
960
612
  // write goes through a sibling temp file + rename, and quit waits for the
@@ -962,10 +614,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
962
614
  const settingsPersistence = createUserSettingsPersistence()
963
615
  const saveStatusline = (items: readonly string[]): void => {
964
616
  statuslineItems = [...items]
965
- void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
966
- .catch((writeError: unknown) => {
967
- bridge.notify(t('notice.statuslineSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
968
- })
617
+ savePreference(settingsPersistence, statuslinePath, 'items', items, message => {
618
+ bridge.notify(t('notice.statuslineSaveFailed', { message }), 'error')
619
+ })
969
620
  }
970
621
 
971
622
  // /vscode-keys: detect the hosting editor's user keybindings.json and pass
@@ -974,7 +625,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
974
625
  const editorKeysEnv: EditorKeysEnv = {
975
626
  env: process.env,
976
627
  paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
977
- flagPath: join(homedir(), '.dsh', 'dsh-code', 'editor-keys.json'),
628
+ flagPath: preferencePath('editor-keys.json'),
978
629
  }
979
630
  const applyEditorKeys = (): Promise<string> => applyCtrlRPassthrough(editorKeysEnv)
980
631
 
@@ -983,45 +634,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
983
634
  // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
984
635
  // auto detection > dark (auto detection itself is a later enhancement and
985
636
  // currently falls back to dark inside theme.ts).
986
- const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
637
+ const themePath = preferencePath('theme.json')
987
638
  let themeWarning: string | undefined
988
639
  if (startup.theme === undefined) {
989
- try {
990
- setTheme(parseThemeName(readSettingsObject(themePath).theme))
991
- } catch (error) {
992
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
993
- themeWarning = error instanceof Error ? error.message : String(error)
994
- }
995
- }
640
+ const read = readPreference(themePath, 'theme', parseThemeName)
641
+ themeWarning = read.warning
642
+ // A missing or corrupt file leaves theme.ts on its own dark default.
643
+ if (read.value !== undefined) setTheme(read.value)
996
644
  } else {
997
645
  setTheme(startup.theme)
998
646
  }
999
647
  const saveTheme = (name: ThemeName): void => {
1000
648
  setTheme(name)
1001
- void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
1002
- .catch((writeError: unknown) => {
1003
- bridge.notify(t('notice.themeSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
1004
- })
649
+ savePreference(settingsPersistence, themePath, 'theme', name, message => {
650
+ bridge.notify(t('notice.themeSaveFailed', { message }), 'error')
651
+ })
1005
652
  }
1006
653
 
1007
654
  // /language persistence: one user-level JSON file beside theme.json. A
1008
655
  // missing file means English; a corrupt file degrades to English with a
1009
656
  // surfaced warning.
1010
- const languagePath = join(homedir(), '.dsh', 'dsh-code', 'language.json')
1011
- let languageWarning: string | undefined
1012
- try {
1013
- setLanguage(parseLanguageName(readSettingsObject(languagePath).language))
1014
- } catch (error) {
1015
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1016
- languageWarning = error instanceof Error ? error.message : String(error)
1017
- }
1018
- }
657
+ const languagePath = preferencePath('language.json')
658
+ const languageRead = readPreference(languagePath, 'language', parseLanguageName)
659
+ const languageWarning: string | undefined = languageRead.warning
660
+ // A missing or corrupt file leaves i18n on its own English default.
661
+ if (languageRead.value !== undefined) setLanguage(languageRead.value)
1019
662
  const saveLanguage = (name: LanguageName): void => {
1020
663
  setLanguage(name)
1021
- void settingsPersistence.save(languagePath, JSON.stringify({ language: name }, null, 2) + '\n')
1022
- .catch((writeError: unknown) => {
1023
- bridge.notify(t('notice.languageSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
1024
- })
664
+ savePreference(settingsPersistence, languagePath, 'language', name, message => {
665
+ bridge.notify(t('notice.languageSaveFailed', { message }), 'error')
666
+ })
1025
667
  }
1026
668
 
1027
669
  // /animation persistence: one user-level JSON file under the DSH home,
@@ -1029,68 +671,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1029
671
  // corrupt file degrades to on with a surfaced warning. Only an explicit
1030
672
  // `false` disables (parseAnimationsPref), so hand-edited or partial files
1031
673
  // never silently freeze the UI.
1032
- const animationsPath = join(homedir(), '.dsh', 'dsh-code', 'animations.json')
1033
- let animationsEnabled = true
1034
- let animationsWarning: string | undefined
1035
- try {
1036
- // A literal `null` file reads as corruption and surfaces the warning the
1037
- // block above promises, instead of a property access on `null`.
1038
- animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations)
1039
- } catch (error) {
1040
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1041
- animationsWarning = error instanceof Error ? error.message : String(error)
1042
- }
1043
- }
674
+ const animationsPath = preferencePath('animations.json')
675
+ // A literal `null` file reads as corruption and surfaces a warning, instead
676
+ // of a property access on `null`.
677
+ const animationsRead = readPreference(animationsPath, 'animations', parseAnimationsPref)
678
+ const animationsWarning: string | undefined = animationsRead.warning
679
+ const animationsEnabled = animationsRead.value ?? true
1044
680
  const saveAnimations = (enabled: boolean): void => {
1045
- void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
1046
- .catch((writeError: unknown) => {
1047
- bridge.notify(t('notice.animationsSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
1048
- })
681
+ savePreference(settingsPersistence, animationsPath, 'animations', enabled, message => {
682
+ bridge.notify(t('notice.animationsSaveFailed', { message }), 'error')
683
+ })
1049
684
  }
1050
685
 
1051
686
  // Global input recall (Codex composer-history contract): one JSONL file
1052
687
  // under the DSH home. A missing file means an empty history; unreadable or
1053
688
  // corrupt content degrades to the valid lines it could parse, silently —
1054
689
  // recall is a convenience surface, never a gate.
1055
- const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
1056
- let inputHistory: readonly string[] = []
1057
- let historyWriteChain: Promise<void> = Promise.resolve()
1058
- try {
1059
- const rawHistory = readFileSync(historyPath, 'utf8')
1060
- inputHistory = parseHistoryFile(rawHistory)
1061
- // Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
1062
- // accumulate in an append-only file; rewrite the canonical form once
1063
- // per boot. The rewrite rides the same chain, so it lands before any
1064
- // submission the user types next. An entry another terminal appends
1065
- // inside the read-to-rename window is dropped — a millisecond-scale
1066
- // gap at boot that recall tolerates by design.
1067
- if (needsCompaction(rawHistory)) {
1068
- historyWriteChain = historyWriteChain
1069
- .then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
1070
- .catch(() => {})
1071
- }
1072
- } catch {
1073
- inputHistory = []
1074
- }
1075
- /**
1076
- * Serialized history writes: each submission appends one JSON line at the
1077
- * end of the file, so concurrent terminals add entries after each other
1078
- * instead of overwriting snapshots they read at their own boot. A
1079
- * multi-line draft still occupies one physical line (JSON escapes the
1080
- * newline), and a regular-length line reaches the disk as one positioned
1081
- * write; an oversized paste may interleave mid-line, which the next
1082
- * parse simply drops.
1083
- */
1084
- const recordHistory = (text: string): void => {
1085
- if (text === '') return
1086
- inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
1087
- historyWriteChain = historyWriteChain
1088
- .then(() => mkdir(dirname(historyPath), { recursive: true }))
1089
- .then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
1090
- .catch((writeError: unknown) => {
1091
- bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
1092
- })
1093
- }
690
+ const inputHistory = createInputHistory(preferencePath('history.jsonl'), message => {
691
+ bridge.notify(t('notice.historySaveFailed', { message }), 'error')
692
+ })
1094
693
 
1095
694
  /** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
1096
695
  const updateQueued = (messageId: string, action: QueueMutation): void => {
@@ -1162,7 +761,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1162
761
  { name: 'dispose', run: () => currentActive.handle.dispose() },
1163
762
  ]),
1164
763
  { name: 'composing', run: () => composing ?? Promise.resolve() },
1165
- { name: 'history', run: () => historyWriteChain },
764
+ { name: 'history', run: () => inputHistory.flush() },
1166
765
  { name: 'settings', run: () => settingsPersistence.flush() },
1167
766
  ]
1168
767
  void runQuitSequence(steps, io.exit, report)
@@ -1669,138 +1268,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1669
1268
  }
1670
1269
  }
1671
1270
 
1672
- const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
1673
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1674
- const records = await sessionQuery.listSessions(signal)
1675
- // Last-activity timestamps for sorting (codex UpdatedAt default): the
1676
- // newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
1677
- // the persistence `locate()` query, so paths are derived from the
1678
- // backend's public config root. Backends without a JSONL config (or
1679
- // vanished directories) fall back to createdAt inside the projection.
1680
- const root = jsonlSessionRoot(persistence)
1681
- const updated = new Map<string, number>()
1682
- if (root !== undefined) {
1683
- await Promise.all(records.map(async record => {
1684
- try {
1685
- const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
1686
- const entries = await readdir(dir, { withFileTypes: true })
1687
- const stats = await Promise.all(
1688
- entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
1689
- .map(entry => stat(join(dir, entry.name))),
1690
- )
1691
- const newest = Math.max(...stats.map(info => info.mtimeMs))
1692
- if (Number.isFinite(newest)) updated.set(record.header.id, newest)
1693
- } catch {
1694
- // Artifact gone or unreadable: the projection falls back to createdAt.
1695
- }
1696
- }))
1697
- }
1698
- const projected = projectSessionRows(records, { ...options, query: '' }, updated)
1699
- // Titles are the expensive fold. Fetch the first picker page when idle;
1700
- // a non-empty query loads more so the displayed title can match.
1701
- const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
1702
- const page = projected.slice(0, titleBudget)
1703
- if (page.length === 0) return projected
1704
- const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
1705
- const titled = mergeSessionTitles(projected, observations)
1706
- return titled.filter(row => sessionRowMatchesQuery(row, options.query))
1707
- }
1708
1271
 
1709
- /**
1710
- * Delete one session subtree (/delete, codex semantics: subagent threads go
1711
- * with their root). The kernel persistence seam has NO deletion API by
1712
- * design logs accumulate "until removed externally" — so this is the
1713
- * controlled external removal, in three phases with a hard boundary
1714
- * between planning and touching the filesystem:
1715
- *
1716
- * 1. `planSessionDeletion` collects the subtree and refuses when the root
1717
- * or ANY member is live (a live child would outlive its deleted
1718
- * parent), ordering the plan children-first.
1719
- * 2. Every plan node must derive to a guarded artifact directory
1720
- * (`encodeSegment(id)` layout beneath the backend's config root).
1721
- * Backends without a derivable artifact (non-JSONL) refuse the WHOLE
1722
- * deletion here — no file has been touched yet, so a backend or layout
1723
- * surprise can never strand a half-deleted subtree.
1724
- * 3. Acquire every node's public persistence write handle before touching
1725
- * files. The JSONL backend holds its cross-process kernel lease for each
1726
- * handle, so another terminal's live session refuses the whole deletion.
1727
- * 4. Artifacts are removed children-first while every lease remains held:
1728
- * only an I/O error mid-delete can stop it short (reported with
1729
- * removed/total counts), leaving the shallowest lineage intact.
1730
- *
1731
- * @param id - the root session id to delete.
1732
- * @returns the outcome line for the panel/notice.
1733
- */
1734
- const deleteSession = async (id: string): Promise<string> => {
1735
- if (sessionQuery === undefined) return 'session query is unavailable in this profile'
1736
- if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
1737
- const records = await sessionQuery.listSessions()
1738
- const plan = planSessionDeletion(records, id)
1739
- if (!plan.ok) return plan.reason
1740
- // Phase 2 completes the plan before the first rm: derive and
1741
- // layout-check every node up front, so a refusal never leaves a
1742
- // partially removed subtree behind.
1743
- const root = jsonlSessionRoot(persistence)
1744
- if (root === undefined || persistence === undefined) {
1745
- return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
1746
- }
1747
- const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
1748
- const dirs = new Map<string, string>()
1749
- for (const node of plan.nodes) {
1750
- const record = byId.get(node.id)
1751
- if (record === undefined) return `no persisted session matches "${node.id}"`
1752
- const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
1753
- if (dir === undefined) {
1754
- return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
1755
- }
1756
- dirs.set(node.id, dir)
1757
- }
1758
- let leases
1759
- try {
1760
- leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
1761
- } catch (error: unknown) {
1762
- if (error instanceof SessionAlreadyOwnedError) {
1763
- return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
1764
- }
1765
- return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
1766
- }
1767
-
1768
- let removed = 0
1769
- let outcome: string | undefined
1770
- for (const node of plan.nodes) {
1771
- const dir = dirs.get(node.id)!
1772
- try {
1773
- // Remove every canonical generation artifact this build knows; other
1774
- // sibling files are never ours to delete. The POSIX session.lock file
1775
- // deliberately remains because unlinking a held flock inode would
1776
- // forfeit the backend's exclusion guarantee.
1777
- const entries = await readdir(dir, { withFileTypes: true })
1778
- for (const entry of entries) {
1779
- if (entry.isFile() && isSessionArtifactName(entry.name)) {
1780
- await rm(join(dir, entry.name), { force: true })
1781
- }
1782
- }
1783
- await rm(dir, { force: true, recursive: false }).catch(() => {})
1784
- removed += 1
1785
- } catch (error: unknown) {
1786
- outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
1787
- break
1788
- }
1789
- }
1790
- outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
1791
- try {
1792
- await releaseSessionDeletionLeases(leases)
1793
- } catch (error: unknown) {
1794
- return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
1795
- }
1796
- return outcome
1797
- }
1798
-
1799
- const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
1800
- if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
1801
- const snapshot = await sessionQuery.readSession(id, signal)
1802
- return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
1803
- }
1272
+ const { loadSessions, deleteSession, loadSessionTranscript } = createSessionIo({
1273
+ sessionQuery,
1274
+ persistence,
1275
+ activeSessionId: () => session?.id,
1276
+ })
1804
1277
 
1805
1278
  /**
1806
1279
  * Read one session's usage blocks for the /usage panel: the mounted
@@ -1821,7 +1294,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1821
1294
  turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
1822
1295
  })
1823
1296
  }
1824
-
1825
1297
  const switchModeAction = async (id: string): Promise<string> => {
1826
1298
  if (id === '') throw new Error('usage: /mode <preset>')
1827
1299
  const currentAgent = agent
@@ -2190,6 +1662,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
2190
1662
  loadModelProviders: () => loadProviderSettings(ctx),
2191
1663
  subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
2192
1664
  saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1665
+ enableModelProviderSubscription: target => enableProviderSubscription(ctx, target),
1666
+ attachSubagent: sessionQuery === undefined ? undefined : {
1667
+ // The seed is the child's durable log; the two buses are the same
1668
+ // process-local channels the root uses, filtered by the child's id
1669
+ // (an Agent's id IS its SessionId).
1670
+ load: (id, signal) => sessionQuery.readSession(id, signal).then(snapshot => snapshot.events),
1671
+ subscribeEvents: (id, onEvent) => {
1672
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
1673
+ if (subject.id === id) onEvent(event)
1674
+ })
1675
+ return off
1676
+ },
1677
+ subscribeStream: (id, onFrame) => {
1678
+ const off = ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
1679
+ if (source.id === id) onFrame(frame)
1680
+ })
1681
+ return off
1682
+ },
1683
+ },
2193
1684
  saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
2194
1685
  discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
2195
1686
  unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
@@ -2254,8 +1745,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
2254
1745
  saveLanguage,
2255
1746
  animations: animationsEnabled,
2256
1747
  saveAnimations,
2257
- history: inputHistory,
2258
- recordHistory,
1748
+ history: inputHistory.entries(),
1749
+ recordHistory: inputHistory.record,
2259
1750
  updateQueued,
2260
1751
  onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
2261
1752
  })
@@ -2334,22 +1825,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
2334
1825
  * @param config - validated startup config resolved from the tuiStartup provider.
2335
1826
  */
2336
1827
  export function apply(ctx: Context, config: Config): void {
2337
- // The CLI validated --theme at parse time; the loose config schema falls
2338
- // back to dark for anything unexpected.
2339
- const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
2340
- const input = {
2341
- ...(theme === undefined ? {} : { theme }),
2342
- ...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
2343
- ...(config.startup.images === undefined ? {} : { images: config.startup.images }),
2344
- }
2345
- const startup: TuiStartup =
2346
- config.startup.kind === 'resume' && config.startup.sessionId !== undefined
2347
- ? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
2348
- : config.startup.kind === 'latest'
2349
- ? { kind: 'latest', ...input }
2350
- : config.startup.kind === 'named' && config.startup.sessionId !== undefined
2351
- ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
2352
- : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
1828
+ // The host resolves every bare @deepseek-ai/* import against its own
1829
+ // installed copies with no version check anywhere on that path, so refuse
1830
+ // to run against an identified-but-different Harness before anything loads.
1831
+ requireHarnessVersion(EXPECTED_HARNESS_VERSION, probeRunningHarness(process.argv[1]))
1832
+ const startup = resolveStartupConfig(config)
2353
1833
  // Read through the global service store, not the property proxy: appExit is
2354
1834
  // an optional host value, never an injected dependency.
2355
1835
  const exit = ctx.get('appExit')