dsh-code 0.7.0 → 0.9.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 (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { randomUUID } from 'node:crypto'
13
13
  import { readFileSync } from 'node:fs'
14
14
  import { homedir } from 'node:os'
15
- import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
15
+ import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
16
16
  import { basename, dirname, join } from 'node:path'
17
17
  import { createElement } from 'react'
18
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -33,23 +33,45 @@ import { App, type NoticeTone } from './app.ts'
33
33
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
34
34
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
35
35
  import { internals, type TuiMount } from './internals.ts'
36
- import { buildModelSelection, loadModelDirectory, resolveEffectiveSelection, type ModelRow } from './models.ts'
36
+ import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
37
+ import {
38
+ loadProviderSettings,
39
+ removeProviderSettings,
40
+ saveProviderCredential,
41
+ subscribeProviderSettings,
42
+ unsetProviderCredential,
43
+ } from './provider-settings.ts'
37
44
  import { createMentions, type MentionsApi } from './mentions.ts'
38
45
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
39
46
  import { createTranscriptStore, type TranscriptStore } from './store.ts'
47
+ import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
40
48
  import { parseStatuslineItems } from './render/status.ts'
41
- import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
49
+ import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
42
50
  import { watchSkills, type SkillsView } from './skills.ts'
43
51
  import { toolArgumentsPreview } from './render/tool-preview.ts'
44
52
  import { buildExportMarkdown } from './render/export.ts'
45
53
  import type { TuiStartup } from './startup.ts'
46
54
  import { SessionSwitchQueue } from './session-switch.ts'
47
- import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
55
+ import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
56
+ import {
57
+ applyPendingPermission,
58
+ cyclePermission as cyclePermissionPreset,
59
+ effectivePermission,
60
+ listPermissionRows,
61
+ permissionPresetsFrom,
62
+ selectPermission,
63
+ } from './permissions.ts'
48
64
  import { listPluginRows } from './plugin-inventory.ts'
49
65
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
50
66
  import {
67
+ collectDeletionSubtree,
68
+ isSubagentSession,
69
+ matchSessionId,
51
70
  mergeSessionTitles,
71
+ newestRootForCwd,
52
72
  projectSessionRows,
73
+ SESSION_ARTIFACT_NAMES,
74
+ sessionArtifactDirectory,
53
75
  type SessionDirectoryOptions,
54
76
  type SessionQueryService,
55
77
  type SessionRow,
@@ -112,6 +134,64 @@ interface Target {
112
134
  cwd?: string
113
135
  }
114
136
 
137
+ /**
138
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
139
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
140
+ * user text: path separators must never leak into the default export filename
141
+ * (which would escape the session cwd).
142
+ * @param id - the session id.
143
+ * @returns at most the last 8 filename-safe characters.
144
+ */
145
+ export function exportSessionIdSuffix(id: string): string {
146
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
147
+ }
148
+
149
+ /** One ordered step of the terminal quit cleanup. */
150
+ export interface QuitCleanupStep {
151
+ /** Step label used in diagnostics and tests. */
152
+ readonly name: string
153
+ /** The step's async work; a rejection is contained by the sequence. */
154
+ readonly run: () => Promise<void>
155
+ }
156
+
157
+ /**
158
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
159
+ * contained (reported through `onError`) so a failed flush or dispose never
160
+ * skips the remaining cleanup; the exit request is always reached exactly
161
+ * once.
162
+ * @param steps - the cleanup steps in dependency order (settle the visible
163
+ * session, await the final in-flight composition, await durable recall).
164
+ * @param exit - the terminal exit request (code 0).
165
+ * @param onError - optional failure sink; called once per failing step and
166
+ * itself contained, so a throwing sink cannot abort the sequence.
167
+ * @returns the names of the steps that started, in order (for tests).
168
+ */
169
+ export async function runQuitSequence(
170
+ steps: readonly QuitCleanupStep[],
171
+ exit: (code: number) => void,
172
+ onError?: (name: string, error: unknown) => void,
173
+ ): Promise<readonly string[]> {
174
+ const started: string[] = []
175
+ for (const step of steps) {
176
+ started.push(step.name)
177
+ try {
178
+ await step.run()
179
+ } catch (error) {
180
+ try {
181
+ onError?.(step.name, error)
182
+ } catch {
183
+ // The failure sink must never abort the cleanup sequence.
184
+ }
185
+ }
186
+ }
187
+ try {
188
+ exit(0)
189
+ } catch {
190
+ // The exit request itself must not become an unhandled rejection.
191
+ }
192
+ return started
193
+ }
194
+
115
195
  /**
116
196
  * Resolve the invocation's target session against the persisted headers.
117
197
  * @param startup - the parsed startup flags.
@@ -120,29 +200,37 @@ interface Target {
120
200
  * @returns the target identity.
121
201
  * @throws with a user-facing message when the flags name nothing resolvable.
122
202
  */
123
- async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
203
+ export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
124
204
  if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
125
- if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
205
+ if (startup.kind === 'named') {
206
+ // The id must not exist yet: reject before any Agent composition when the
207
+ // backend can tell us (a live collision is still caught by the session
208
+ // store at create time).
209
+ if (persistence !== undefined) {
210
+ const headers: readonly SessionHeader[] = await persistence.list()
211
+ if (headers.some(header => header.id === startup.sessionId)) {
212
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
213
+ }
214
+ }
215
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
216
+ }
126
217
  if (persistence === undefined) {
127
218
  throw new Error('cannot resolve the requested session: session persistence is not configured')
128
219
  }
129
220
  const headers: readonly SessionHeader[] = await persistence.list()
130
221
  if (startup.kind === 'resume') {
131
- const wanted = startup.sessionId
132
- const exact = headers.filter(header => header.id === wanted)
133
- const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
134
- if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
135
- if (matches.length > 1) {
136
- throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
137
- }
138
- return { sessionId: matches[0]!.id, resume: true }
139
- }
140
- // --continue: the newest persisted session whose header pins this cwd.
141
- const local = headers
142
- .filter(header => header.cwd === cwd)
143
- .sort((left, right) => right.createdAt - left.createdAt)
144
- if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
145
- return { sessionId: local[0]!.id, resume: true }
222
+ const matched = matchSessionId(headers, startup.sessionId)
223
+ // Subagent conversations are read-only everywhere else; the CLI must not
224
+ // be a back door into appending root turns to a child's durable log.
225
+ if (isSubagentSession(matched)) {
226
+ throw new Error('subagent conversations are read-only; resume a root session')
227
+ }
228
+ return { sessionId: matched.id, resume: true }
229
+ }
230
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
231
+ const newest = newestRootForCwd(headers, cwd)
232
+ if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
233
+ return { sessionId: newest.id, resume: true }
146
234
  }
147
235
 
148
236
  /**
@@ -190,9 +278,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
190
278
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
191
279
 
192
280
  const cwd = process.cwd()
193
- const defaults = defaultModel.currentSelection()
281
+ // Live deployment default (web selectModel parity): read on every use, not
282
+ // snapshotted at launch, so a /model pick this process saves becomes the
283
+ // default for sessions composed afterwards without a restart.
284
+ const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
194
285
  const presets = agentPresetsFrom(ctx)
195
286
  if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
287
+ const permissionPresets = permissionPresetsFrom(ctx)
196
288
 
197
289
  // A bare fresh launch stays transient: no Agent or session is composed, and
198
290
  // nothing is persisted, until the user's first real input. Explicit flags
@@ -219,7 +311,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
219
311
  const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
220
312
  ? {}
221
313
  : { picked: pendingSelection }
222
- let mode = next.mode
314
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode
223
315
  if (!next.resume) mode = (await presets.resolve(mode)).id
224
316
  const setup = async (agentCtx: Context): Promise<void> => {
225
317
  const sessionPreset = next.resume
@@ -229,26 +321,38 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
229
321
  mode = mounted.id
230
322
  const selection: ModelSelectionRef = {
231
323
  get current(): ModelSelection | undefined {
232
- return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, defaults)
324
+ return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
233
325
  },
234
326
  set current(value: ModelSelection | undefined) { selectionState.picked = value },
235
327
  assembled: undefined,
236
328
  }
237
329
  installModelSelection(agentCtx, selection)
238
330
  }
331
+ // AgentOptions seed the loop's fallback route; effort rides the selection
332
+ // ref (installModelSelection), so only the provider/model pair is seeded.
333
+ const seedOptions = pendingSelection === undefined
334
+ ? { provider: currentDefaults().provider, model: currentDefaults().model }
335
+ : { provider: pendingSelection.provider, model: pendingSelection.model }
239
336
  const handle = next.resume
240
337
  ? await agents.resume({
241
338
  resumeSessionId: SessionId(next.sessionId),
242
- agentOptions: { provider: defaults.provider, model: defaults.model },
339
+ agentOptions: seedOptions,
340
+ // Quit aborts an in-flight composition so the exit wait never hangs
341
+ // on a prepare that cannot settle; upstream rolls the creation back.
342
+ signal: quitAbort.signal,
243
343
  setup,
244
344
  })
245
345
  : await agents.create({
246
346
  sessionId: SessionId(next.sessionId),
247
347
  meta: { cwd: nextCwd, agentPreset: mode },
248
- agentOptions: { provider: defaults.provider, model: defaults.model },
348
+ agentOptions: seedOptions,
349
+ signal: quitAbort.signal,
249
350
  setup,
250
351
  })
251
352
  const session = handle.agent.session
353
+ if (!next.resume && permissionPresets !== undefined) {
354
+ applyPendingPermission(permissionPresets, session, pendingPermission)
355
+ }
252
356
  const sessionCwd = session.header.cwd ?? nextCwd
253
357
  return {
254
358
  handle,
@@ -266,12 +370,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
266
370
  let agent: Agent | undefined
267
371
  let session: Session | undefined
268
372
  let store: TranscriptStore = createTranscriptStore()
373
+ // Live subagent activity (child sessions of the current root): one bounded
374
+ // row per child, folded from the same event bus the transcript feeds on.
375
+ const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
269
376
  // File-only mentions from the start: `@` completion works on a bare launch
270
377
  // (no session yet); the prepare/activate paths replace this with the full
271
378
  // agent-scoped instance that also resolves session references.
272
379
  let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
273
380
  /** Explicit model pick made before any session exists (a bare launch). */
274
381
  let pendingSelection: ModelSelection | undefined
382
+ /** Agent preset selected before the first session exists. */
383
+ let pendingMode: string | undefined
384
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
385
+ let pendingModeWork: Promise<void> = Promise.resolve()
386
+ /** Permission preset selected before the first session exists. */
387
+ let pendingPermission: string | undefined
388
+ /**
389
+ * Monotonic session epoch: bumped on every successful activation, on every
390
+ * first-session creation, and on quit. Async callbacks (mention prepares,
391
+ * command executions) capture it at call time and drop their result when it
392
+ * changed, so a stale callback can never deliver to an agent that is no
393
+ * longer on screen.
394
+ */
395
+ let epoch = 0
396
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
397
+ const quitAbort = new AbortController()
398
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
399
+ const pendingControllers = new Set<AbortController>()
400
+ const abortPendingControllers = (): void => {
401
+ for (const controller of [...pendingControllers]) {
402
+ pendingControllers.delete(controller)
403
+ controller.abort()
404
+ }
405
+ }
406
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
407
+ let composing: Promise<void> | undefined
408
+ /**
409
+ * Run one session composition exclusively: concurrent compositions wait
410
+ * their turn, so a bare-launch first-session creation and a /resume
411
+ * activation can never compose agents in parallel (the loser would leak its
412
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
413
+ * always continues.
414
+ */
415
+ const compose = (work: () => Promise<void>): Promise<void> => {
416
+ const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
417
+ composing = turn.catch(() => {})
418
+ return turn
419
+ }
275
420
 
276
421
  if (!lazy) {
277
422
  const target = await resolveTarget(startup, persistence, cwd)
@@ -288,7 +433,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
288
433
  // before the first render. The handler reads the current session/store, so
289
434
  // the deferred first session of a bare launch is covered by the same feed.
290
435
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
291
- if (session !== undefined && subject.id === session.id) store.apply(event)
436
+ if (session === undefined) return
437
+ if (subject.id === session.id) {
438
+ store.apply(event)
439
+ return
440
+ }
441
+ // Child sessions (subagent conversations this root spawned) fold into
442
+ // the bounded live-activity feed, never the transcript: the root stays
443
+ // the only durable transcript truth while a running subagent remains
444
+ // visible. Lineage comes from the child header, same field the session
445
+ // directory uses to tag `↳` rows.
446
+ if (subject.header.parentSession === session.id) subagents.apply(subject.id, event)
292
447
  })
293
448
 
294
449
  const commands: CommandsView = watchCommands(ctx)
@@ -306,6 +461,28 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
306
461
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
307
462
  )
308
463
 
464
+ // Subagent model routing. The kernel seeds child agents from the parent's
465
+ // CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
466
+ // /model switch never touches — delegated work would keep running on the
467
+ // launch-time route. This plugin-level listener mirrors installModelSelection
468
+ // for subagent-origin requests (scope filtering delivers the agent subject
469
+ // inside the payload): the explicit /subagent override wins, else the root's
470
+ // effective selection (explicit pick > session header > deployment default).
471
+ // Effort rides the selection exactly like the kernel listener applies it.
472
+ let subagentOverride: ModelSelection | undefined
473
+ ctx.on('agent/request', (payload, next) => {
474
+ const subject = payload.agent
475
+ const header = subject.session.header
476
+ if (header.parentSession === undefined && header.origin !== 'subagent') return next()
477
+ const picked = subagentOverride
478
+ ?? resolveEffectiveSelection(
479
+ active?.selection.picked ?? pendingSelection,
480
+ subject.session.requestHeader()?.config,
481
+ currentDefaults(),
482
+ )
483
+ return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
484
+ })
485
+
309
486
  // ask_user_question provider: the single UI provider on the shared service,
310
487
  // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
311
488
  // through this same pipe.
@@ -378,18 +555,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
378
555
  } catch {
379
556
  inputHistory = []
380
557
  }
558
+ /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
559
+ let historyWriteChain: Promise<void> = Promise.resolve()
381
560
  const recordHistory = (text: string): void => {
382
561
  if (text === '') return
383
562
  inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
384
- // A missing file on the first save is not an error: start from empty.
385
- let current = ''
386
- try {
387
- current = readFileSync(historyPath, 'utf8')
388
- } catch {
389
- current = ''
390
- }
391
- void mkdir(dirname(historyPath), { recursive: true })
392
- .then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
563
+ // Write the whole current list, serialized per submission: the file is
564
+ // never read back on the submit path, so rapid same-process submissions
565
+ // cannot lose entries to a read-modify-write race.
566
+ historyWriteChain = historyWriteChain
567
+ .then(() => mkdir(dirname(historyPath), { recursive: true }))
568
+ .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
393
569
  .catch((writeError: unknown) => {
394
570
  bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
395
571
  })
@@ -415,37 +591,43 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
415
591
  if (quitting) return
416
592
  quitting = true
417
593
  switchQueue.cancel()
594
+ // Stale prepares/commands die with the session they were for. Aborting
595
+ // the composition signal lets a never-settling prepare reject, so the
596
+ // exit wait below cannot hang (upstream rolls the creation back).
597
+ abortPendingControllers()
598
+ quitAbort.abort()
599
+ epoch += 1
418
600
  off()
419
601
  mountRef.current?.unmount()
420
- // A bare launch that exits before the first input has no session: exit
421
- // cleanly without flushing or disposing anything.
422
602
  const currentSession = session
423
603
  const currentActive = active
424
- if (currentSession === undefined || currentActive === undefined) {
425
- io.exit(0)
426
- return
604
+ const report = (name: string, error: unknown): void => {
605
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
427
606
  }
428
- void sessions.flush(currentSession)
429
- .catch((flushError: unknown) => {
430
- // The session log already carries every durable event; a failed flush
431
- // must not trap the user in a dead terminal, so report and still exit.
432
- internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
433
- })
434
- .then(() => currentActive.handle.dispose())
435
- .catch((disposeError: unknown) => {
436
- internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
437
- })
438
- .then(() => { io.exit(0) })
607
+ // One ordered cleanup: settle the visible session (if any — a bare launch
608
+ // that never composed one resolves immediately), then wait for the final
609
+ // in-flight composition (its work swallows errors and the quitting guard
610
+ // disposes any half-prepared agent), then flush the durable recall, then
611
+ // request exit. `composing` and `historyWriteChain` are read at step run
612
+ // time, so a turn that was still being queued when quit ran is included.
613
+ // A failing step must never skip the remaining cleanup.
614
+ const steps: QuitCleanupStep[] = [
615
+ ...(currentSession === undefined || currentActive === undefined
616
+ ? []
617
+ : [
618
+ { name: 'flush', run: async () => { await sessions.flush(currentSession) } },
619
+ { name: 'dispose', run: () => currentActive.handle.dispose() },
620
+ ]),
621
+ { name: 'composing', run: () => composing ?? Promise.resolve() },
622
+ { name: 'history', run: () => historyWriteChain },
623
+ ]
624
+ void runQuitSequence(steps, io.exit, report)
439
625
  }
440
626
 
441
627
  /** Run one slash line through the command registry (closed namespace). */
442
628
  const runSlash = (line: string): void => {
443
629
  const currentAgent = agent
444
630
  if (currentAgent === undefined) return
445
- if (line.startsWith('/mode ')) {
446
- void switchModeAction(line.slice(6).trim())
447
- return
448
- }
449
631
  if (line.startsWith('/resume ')) {
450
632
  requestResume(line.slice(8).trim())
451
633
  return
@@ -456,7 +638,16 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
456
638
  return
457
639
  }
458
640
  const controller = new AbortController()
641
+ const atEpoch = epoch
642
+ pendingControllers.add(controller)
643
+ const finish = (): void => {
644
+ pendingControllers.delete(controller)
645
+ }
459
646
  void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
647
+ finish()
648
+ // A switch/quit landed while the command ran: its fall-through must not
649
+ // reach an agent that is no longer on screen.
650
+ if (epoch !== atEpoch || agent !== currentAgent) return
460
651
  if (execution === undefined) {
461
652
  // No command owns this line: send it verbatim so a user-invocable
462
653
  // skill gesture (`/skill-name`) reaches the host's tool-skill
@@ -471,6 +662,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
471
662
  }
472
663
  }
473
664
  }, (error: unknown) => {
665
+ finish()
666
+ if (epoch !== atEpoch || agent !== currentAgent) return
474
667
  bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
475
668
  })
476
669
  }
@@ -493,7 +686,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
493
686
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
494
687
  return
495
688
  }
689
+ const atEpoch = epoch
496
690
  const deliver = (readable: string, context?: UserMessage): void => {
691
+ // A switch/quit landed while the snapshot was being prepared: never
692
+ // deliver to an agent that is no longer on screen.
693
+ if (epoch !== atEpoch || agent !== currentAgent) return
497
694
  // Session snapshots ride the inbox as model-facing context ahead of
498
695
  // the readable message (upstream README wiring: inject before the
499
696
  // followup/steer that wakes the driver).
@@ -519,10 +716,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
519
716
  return
520
717
  }
521
718
  const controller = new AbortController()
719
+ pendingControllers.add(controller)
522
720
  void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
721
+ pendingControllers.delete(controller)
523
722
  deliver(prepared.text, prepared.additionalContext)
524
723
  }, (error: unknown) => {
525
- if (controller.signal.aborted) return
724
+ pendingControllers.delete(controller)
725
+ if (controller.signal.aborted || epoch !== atEpoch) return
526
726
  bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
527
727
  })
528
728
  }
@@ -533,47 +733,84 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
533
733
  // failure reports and clears the queue, leaving the transient state ready
534
734
  // for the next attempt.
535
735
  const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
536
- let creating: Promise<void> | undefined
736
+ // A creation is queued/running: further submissions must not mint more
737
+ // fresh sessions (their lines queue into pendingInputs instead).
738
+ let creating = false
537
739
  const ensureSession = (mode?: string): void => {
538
- if (creating !== undefined) return
539
- const attempt = (async () => {
540
- const next = await prepare({
541
- sessionId: `session-${randomUUID()}`,
542
- resume: false,
543
- ...(mode === undefined ? {} : { mode }),
544
- })
545
- if (quitting) {
546
- void next.handle.dispose().catch(() => {})
547
- return
740
+ if (creating) return
741
+ creating = true
742
+ void compose(async () => {
743
+ try {
744
+ // A direct `/mode <preset>` resolves asynchronously. Preserve submit
745
+ // order so the first composition cannot race ahead with the old mode.
746
+ await pendingModeWork
747
+ // Another composition (e.g. a /resume activated while this creation
748
+ // waited its turn) may have published a session already: deliver the
749
+ // queued lines there instead of minting a competing fresh session
750
+ // (which would orphan the live one without a dispose).
751
+ if (session !== undefined) {
752
+ const queued = pendingInputs.splice(0)
753
+ for (const item of queued) deliverLine(item.text, item.mode)
754
+ return
755
+ }
756
+ const next = await prepare({
757
+ sessionId: `session-${randomUUID()}`,
758
+ resume: false,
759
+ ...(mode === undefined ? {} : { mode }),
760
+ })
761
+ if (quitting) {
762
+ void next.handle.dispose().catch(() => {})
763
+ return
764
+ }
765
+ active = next
766
+ agent = next.agent
767
+ session = next.session
768
+ store = next.store
769
+ mentions = next.mentions
770
+ subagents.reset()
771
+ pendingMode = undefined
772
+ pendingPermission = undefined
773
+ commands.setAgent(agent)
774
+ skills.setAgent(agent)
775
+ // The App mounts with a placeholder key until the first input; the
776
+ // key-change remount below must start from a clean screen or the ghost
777
+ // static header stays visible above the new one (same source-backed
778
+ // clear the session-switch path performs).
779
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
780
+ renderCurrent()
781
+ abortPendingControllers()
782
+ epoch += 1
783
+ const queued = pendingInputs.splice(0)
784
+ for (const item of queued) deliverLine(item.text, item.mode)
785
+ } finally {
786
+ creating = false
548
787
  }
549
- active = next
550
- agent = next.agent
551
- session = next.session
552
- store = next.store
553
- mentions = next.mentions
554
- commands.setAgent(agent)
555
- skills.setAgent(agent)
556
- // The App mounts with a placeholder key until the first input; the
557
- // key-change remount below must start from a clean screen or the ghost
558
- // static header stays visible above the new one (same source-backed
559
- // clear the session-switch path performs).
560
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
561
- renderCurrent()
562
- const queued = pendingInputs.splice(0)
563
- for (const item of queued) deliverLine(item.text, item.mode)
564
- })().catch((error: unknown) => {
788
+ }).catch((error: unknown) => {
565
789
  pendingInputs.length = 0
566
790
  bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
567
- }).finally(() => {
568
- creating = undefined
569
791
  })
570
- creating = attempt
571
792
  }
572
793
 
573
794
  /** Deliver one readable line to the agent, expanding session mentions first. */
574
795
  const send = (text: string, mode: 'followup' | 'steer'): void => {
575
796
  const line = text.trim()
576
797
  if (line === '') return
798
+ if (line.startsWith('/mode ')) {
799
+ void switchModeAction(line.slice(6).trim()).then(
800
+ selected => bridge.notify(`mode → ${selected}`),
801
+ error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
802
+ )
803
+ return
804
+ }
805
+ if (line.startsWith('/permission ')) {
806
+ try {
807
+ const selected = setPermissionAction(line.slice(12).trim())
808
+ bridge.notify(`permission → ${selected}`)
809
+ } catch (error: unknown) {
810
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
811
+ }
812
+ return
813
+ }
577
814
  if (session === undefined) {
578
815
  pendingInputs.push({ text: line, mode })
579
816
  ensureSession()
@@ -609,29 +846,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
609
846
  }
610
847
  }
611
848
 
849
+ /** Select one permission preset before the first session or on the active one. */
850
+ const setPermissionAction = (id: string): string => {
851
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
852
+ throw new Error('permission presets are not mounted in this composition')
853
+ }
854
+ if (id === '') throw new Error('usage: /permission <preset>')
855
+ const selected = selectPermission(permissionPresets, session, id)
856
+ if (session === undefined) {
857
+ pendingPermission = selected
858
+ renderCurrent()
859
+ }
860
+ return selected
861
+ }
862
+
612
863
  /**
613
- * Cycle to the next permission preset (Shift+Tab, the Claude-Code
614
- * permission-mode convention mapped onto dsh presets). A session in a
615
- * custom knob state wraps to the first declared preset.
864
+ * Cycle to the next permission preset (Shift+Tab). Before the first session,
865
+ * the choice remains process-local and is materialized when Harness creates
866
+ * that session; afterwards the canonical service writes durable events.
616
867
  */
617
868
  const cyclePermission = (): string => {
618
- if (session === undefined) throw new Error('no session yet — submit a message to start')
619
- const service = ctx.get('permissionPresets') as
620
- | {
621
- names: readonly string[]
622
- current(events: readonly SessionEvent[]): string
623
- set(target: Session, preset: string): void
624
- }
625
- | undefined
626
- if (service === undefined || service.names.length === 0) {
869
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
627
870
  bridge.notify('permission presets are not mounted in this composition', 'warning')
628
871
  return ''
629
872
  }
630
- const at = service.names.indexOf(service.current(session.events))
631
- const next = service.names[(at + 1) % service.names.length] ?? ''
632
- if (next === '') return ''
633
873
  try {
634
- service.set(session, next)
874
+ const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
875
+ if (session === undefined && next !== '') {
876
+ pendingPermission = next
877
+ renderCurrent()
878
+ }
635
879
  return next
636
880
  } catch (error: unknown) {
637
881
  bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
@@ -654,9 +898,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
654
898
  } else {
655
899
  active.selection.picked = selection
656
900
  }
901
+ // Global default (web selectModel parity): every pick is persisted as the
902
+ // deployment default through the same agentDefaultModel service the web
903
+ // host writes, so the choice survives restarts and other surfaces read
904
+ // it. Save failures degrade to a notice — the in-session switch already
905
+ // took effect and must not roll back (the web contract).
906
+ void defaultModel.saveSelection(selection).catch((error: unknown) => {
907
+ bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
908
+ })
909
+ // Advisory immediate validation (web selectModel parity): run the same
910
+ // local resolveCallConfig check the request pipeline would, so a stale
911
+ // directory — an effort the adapter withdrew since /model loaded —
912
+ // surfaces as a pick-time notice instead of failing the next assembled
913
+ // step. Best-effort: an llm service without the resolver keeps the
914
+ // existing request-boundary rejection. Called as a method (`this`-bound)
915
+ // like resolveModelInfo in models.ts.
916
+ const llm = ctx.get('llm')
917
+ const resolveCallConfig = (llm as {
918
+ resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
919
+ } | undefined)?.resolveCallConfig
920
+ if (llm !== undefined && typeof resolveCallConfig === 'function') {
921
+ void Promise.resolve(resolveCallConfig.call(llm, {
922
+ provider: selection.provider,
923
+ model: selection.model,
924
+ ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
925
+ })).catch((error: unknown) => {
926
+ bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
927
+ })
928
+ }
657
929
  return `${row.provider}/${row.model}`
658
930
  }
659
931
 
932
+ /** The /subagent override label, '' when delegated agents follow the current model. */
933
+ const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
934
+
935
+ /** Apply one /subagent model pick; returns the override label. */
936
+ const setSubagentModel = (row: ModelRow, effortId?: string): string => {
937
+ subagentOverride = buildModelSelection(row, effortId)
938
+ renderCurrent()
939
+ return modelSelectionLabel(subagentOverride)
940
+ }
941
+
942
+ /** Drop the /subagent override: delegated agents follow the current model again. */
943
+ const clearSubagentModel = (): void => {
944
+ subagentOverride = undefined
945
+ renderCurrent()
946
+ }
947
+
660
948
  /**
661
949
  * Export the folded transcript to a markdown file (/export). The default
662
950
  * target sits beside the session's cwd so the file lands in the user's
@@ -669,7 +957,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
669
957
  }
670
958
  const wanted = argument.trim()
671
959
  const sessionCwd = session.header.cwd ?? cwd
672
- const defaultName = `dsh-session-${session.id.slice(-8)}.md`
960
+ // The default name derives from the session id, which `--session` lets the
961
+ // user spell freely: reduce it to filename-safe characters first so the
962
+ // default target can never escape the session cwd.
963
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
673
964
  const target = wanted === ''
674
965
  ? join(sessionCwd, defaultName)
675
966
  : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
@@ -705,7 +996,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
705
996
 
706
997
  const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
707
998
  if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
708
- const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
999
+ const records = await sessionQuery.listSessions(signal)
1000
+ // Last-activity timestamps for sorting (codex UpdatedAt default): the
1001
+ // JSONL artifact's mtime via locate()+stat — the upstream api-proxy's own
1002
+ // cold-probe pattern. O(1) per session; backends without a location (or
1003
+ // vanished files) fall back to createdAt inside the projection.
1004
+ const updated = new Map<string, number>()
1005
+ for (const record of records) {
1006
+ const location = persistence?.locate(record.header)
1007
+ if (location === undefined) continue
1008
+ try {
1009
+ updated.set(record.header.id, (await stat(location.path)).mtimeMs)
1010
+ } catch {
1011
+ // Artifact gone or unreadable: the projection falls back to createdAt.
1012
+ }
1013
+ }
1014
+ const projected = projectSessionRows(records, options, updated)
709
1015
  // Titles are the expensive fold. Fetch only the first bounded picker page;
710
1016
  // navigation/filter changes trigger a fresh, cancellable observation.
711
1017
  const page = projected.slice(0, 32)
@@ -714,6 +1020,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
714
1020
  return mergeSessionTitles(projected, observations)
715
1021
  }
716
1022
 
1023
+ /**
1024
+ * Delete one session subtree (/delete, codex semantics: subagent threads go
1025
+ * with their root). The kernel persistence seam has NO deletion API by
1026
+ * design — logs accumulate "until removed externally" — so this is the
1027
+ * controlled external removal: guards (live/current refusal, subtree
1028
+ * collection, and the JSONL layout check `encodeSegment(id)/session.jsonl`)
1029
+ * run before any filesystem touch, and only the backend-located artifacts
1030
+ * are removed. Backends without a locatable artifact (SQLite) are refused.
1031
+ * @param id - the root session id to delete.
1032
+ * @returns the outcome line for the panel/notice.
1033
+ */
1034
+ const deleteSession = async (id: string): Promise<string> => {
1035
+ if (sessionQuery === undefined) return 'session query is unavailable in this profile'
1036
+ if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
1037
+ const records = await sessionQuery.listSessions()
1038
+ const target = records.find(record => record.header.id === id)
1039
+ if (target === undefined) return `no persisted session matches "${id}"`
1040
+ if (target.live) return 'cannot delete a live session — it is open in this or another process'
1041
+ const doomed = collectDeletionSubtree(records, id)
1042
+ const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
1043
+ let removed = 0
1044
+ for (const candidate of doomed) {
1045
+ const record = byId.get(candidate)
1046
+ if (record === undefined || record.live) continue
1047
+ const location = persistence?.locate(record.header)
1048
+ if (location === undefined) {
1049
+ return `session backend exposes no deletable artifact for ${candidate.slice(-12)} (deletion is unsupported on this backend)`
1050
+ }
1051
+ const dir = sessionArtifactDirectory(location.path, candidate)
1052
+ if (dir === undefined) {
1053
+ return `refusing to delete: unexpected artifact layout at ${location.path}`
1054
+ }
1055
+ try {
1056
+ for (const name of SESSION_ARTIFACT_NAMES) {
1057
+ await rm(join(dir, name), { force: true })
1058
+ }
1059
+ // Remove the now-empty session directory; a non-empty one stays (an
1060
+ // unexpected sibling file is never ours to delete).
1061
+ await rm(dir, { force: true, recursive: false }).catch(() => {})
1062
+ removed += 1
1063
+ } catch (error: unknown) {
1064
+ return `delete failed for ${candidate.slice(-12)}: ${error instanceof Error ? error.message : String(error)}`
1065
+ }
1066
+ }
1067
+ return `deleted ${removed} session${removed === 1 ? '' : 's'}`
1068
+ }
1069
+
717
1070
  const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
718
1071
  if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
719
1072
  const snapshot = await sessionQuery.readSession(id, signal)
@@ -723,12 +1076,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
723
1076
  const switchModeAction = async (id: string): Promise<string> => {
724
1077
  if (id === '') throw new Error('usage: /mode <preset>')
725
1078
  const currentAgent = agent
726
- const currentActive = active
727
- if (currentAgent === undefined || currentActive === undefined) {
728
- throw new Error('no session yet submit a message to start')
1079
+ if (currentAgent === undefined) {
1080
+ const choice = pendingModeWork.then(async () => {
1081
+ const preset = await selectPreset(presets, undefined, id)
1082
+ // A resume may have won while this roster read was in flight; never
1083
+ // leak the old pending choice into a later /new session.
1084
+ if (agent === undefined) {
1085
+ pendingMode = preset.id
1086
+ renderCurrent()
1087
+ }
1088
+ return preset.id
1089
+ })
1090
+ pendingModeWork = choice.then(() => {}, () => {})
1091
+ return choice
729
1092
  }
730
- const preset = await switchPreset(presets, currentAgent, id)
731
- currentActive.mode = preset.id
1093
+
1094
+ const preset = await selectPreset(presets, currentAgent, id)
1095
+ if (active === undefined) throw new Error('active Agent has no session state')
1096
+ active.mode = preset.id
732
1097
  commands.setAgent(currentAgent)
733
1098
  skills.setAgent(currentAgent)
734
1099
  renderCurrent()
@@ -737,52 +1102,70 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
737
1102
 
738
1103
  interface PendingSwitch { readonly target: Target; readonly label: string }
739
1104
 
740
- const activate = async (nextTarget: Target): Promise<void> => {
741
- const previous = active
742
- const next = await prepare(nextTarget)
743
- active = next
744
- agent = next.agent
745
- session = next.session
746
- store = next.store
747
- mentions = next.mentions
748
- commands.setAgent(agent)
749
- skills.setAgent(agent)
750
- try {
751
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
752
- renderCurrent()
753
- } catch (error: unknown) {
754
- active = previous
755
- agent = previous?.agent
756
- session = previous?.session
757
- store = previous === undefined ? createTranscriptStore() : previous.store
758
- mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
759
- if (agent !== undefined) commands.setAgent(agent)
760
- if (agent !== undefined) skills.setAgent(agent)
761
- await next.handle.dispose()
762
- renderCurrent()
763
- throw error
764
- }
765
- // No previous session (a bare launch switched straight into a resume):
766
- // nothing to flush or dispose, so just confirm the activation.
767
- if (previous === undefined) {
768
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
769
- return
770
- }
771
- let cleanupWarning: string | undefined
772
- try {
773
- await sessions.flush(previous.session)
774
- } catch (error: unknown) {
775
- cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
776
- }
777
- try {
778
- await previous.handle.dispose()
779
- } catch (error: unknown) {
780
- cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
781
- }
782
- bridge.notify(cleanupWarning === undefined
783
- ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
784
- : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
785
- cleanupWarning === undefined ? 'info' : 'warning')
1105
+ const activate = (nextTarget: Target): Promise<void> => {
1106
+ if (quitting) return Promise.resolve()
1107
+ // Serialized with every other composition (bare-launch creation, queued
1108
+ // switches): at most one agent is composed at a time.
1109
+ return compose(async () => {
1110
+ const previous = active
1111
+ const next = await prepare(nextTarget)
1112
+ // Quit landed while the next session was being composed: dispose the
1113
+ // half-ready agent and leave the current session untouched.
1114
+ if (quitting) {
1115
+ await next.handle.dispose().catch(() => {})
1116
+ return
1117
+ }
1118
+ active = next
1119
+ agent = next.agent
1120
+ session = next.session
1121
+ store = next.store
1122
+ mentions = next.mentions
1123
+ subagents.reset()
1124
+ pendingMode = undefined
1125
+ pendingPermission = undefined
1126
+ commands.setAgent(agent)
1127
+ skills.setAgent(agent)
1128
+ try {
1129
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1130
+ renderCurrent()
1131
+ } catch (error: unknown) {
1132
+ active = previous
1133
+ agent = previous?.agent
1134
+ session = previous?.session
1135
+ store = previous === undefined ? createTranscriptStore() : previous.store
1136
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
1137
+ if (agent !== undefined) commands.setAgent(agent)
1138
+ if (agent !== undefined) skills.setAgent(agent)
1139
+ await next.handle.dispose()
1140
+ if (!quitting) renderCurrent()
1141
+ throw error
1142
+ }
1143
+ // From here the new session is live: in-flight prepares/commands for
1144
+ // the previous agent are stale and must be aborted and ignored.
1145
+ abortPendingControllers()
1146
+ epoch += 1
1147
+ // No previous session (a bare launch switched straight into a resume):
1148
+ // nothing to flush or dispose, so just confirm the activation.
1149
+ if (previous === undefined) {
1150
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1151
+ return
1152
+ }
1153
+ let cleanupWarning: string | undefined
1154
+ try {
1155
+ await sessions.flush(previous.session)
1156
+ } catch (error: unknown) {
1157
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
1158
+ }
1159
+ try {
1160
+ await previous.handle.dispose()
1161
+ } catch (error: unknown) {
1162
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
1163
+ }
1164
+ bridge.notify(cleanupWarning === undefined
1165
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1166
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
1167
+ cleanupWarning === undefined ? 'info' : 'warning')
1168
+ })
786
1169
  }
787
1170
 
788
1171
  const switchQueue = new SessionSwitchQueue<PendingSwitch>(
@@ -818,13 +1201,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
818
1201
  const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
819
1202
  if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
820
1203
  if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
821
- if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
822
- throw new Error('subagent conversations are read-only in /resume; resume a root session')
1204
+ const matched = matches[0]!
1205
+ // Same lineage gate as the CLI --resume path and the picker.
1206
+ if (isSubagentSession(matched.header)) {
1207
+ throw new Error('subagent conversations are read-only; resume a root session')
823
1208
  }
824
- if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
1209
+ if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
825
1210
  throw new Error('that session is already live in another owner')
826
1211
  }
827
- return matches[0]!.header.id
1212
+ return matched.header.id
828
1213
  }
829
1214
 
830
1215
  const requestResume = (wanted: string): void => {
@@ -857,13 +1242,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
857
1242
  }
858
1243
 
859
1244
  const appElement = (): ReturnType<typeof createElement> => {
860
- // A bare launch mounts with placeholder facts until the first input
861
- // composes a real session: empty session id/mode, the deployment default
862
- // model, and the working directory's basename. `status.ts` drops empty
863
- // mode/sessionId, so the bar renders only the identity it actually has.
1245
+ // A bare launch mounts with pending/default model, mode, and permission
1246
+ // facts until the first input composes the real session. These choices stay
1247
+ // process-local and create no durable state before that composition.
864
1248
  const sessionCwd = session?.header.cwd ?? cwd
865
- const model = store.getView().model !== ''
866
- ? store.getView().model
1249
+ const currentView = store.getView()
1250
+ const defaults = currentDefaults()
1251
+ const model = currentView.model !== ''
1252
+ ? currentView.model
867
1253
  : pendingSelection !== undefined
868
1254
  ? `${pendingSelection.provider}/${pendingSelection.model}`
869
1255
  : `${defaults.provider}/${defaults.model}`
@@ -872,11 +1258,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
872
1258
  session?.requestHeader()?.config,
873
1259
  defaults,
874
1260
  ).reasoningEffort
1261
+ const permission = permissionPresets === undefined
1262
+ ? currentView.permission
1263
+ : effectivePermission(permissionPresets, session, pendingPermission)
875
1264
  return createElement(App, {
876
1265
  key: session?.id ?? 'pending',
877
1266
  store,
878
1267
  approval,
879
1268
  questions,
1269
+ subagents,
880
1270
  commands,
881
1271
  skills,
882
1272
  model,
@@ -886,22 +1276,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
886
1276
  branch: gitBranch(sessionCwd),
887
1277
  sessionId: session === undefined ? '' : session.id.slice(-8),
888
1278
  resumed: active?.resumed ?? false,
889
- mode: active?.mode ?? '',
1279
+ mode: active?.mode ?? pendingMode ?? presets.defaultId,
1280
+ permission,
890
1281
  dispatch,
891
1282
  steer,
892
1283
  interrupt,
893
1284
  quit,
894
1285
  loadModels: () => loadModelDirectory(ctx),
1286
+ loadModelProviders: () => loadProviderSettings(ctx),
1287
+ subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1288
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1289
+ unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1290
+ removeModelProvider: target => removeProviderSettings(ctx, target),
895
1291
  loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
896
1292
  cyclePermission,
1293
+ setPermission: setPermissionAction,
897
1294
  selectModel,
1295
+ subagentModel: subagentModelLabel(),
1296
+ setSubagentModel,
1297
+ clearSubagentModel,
1298
+ deleteSession,
898
1299
  exportTranscript,
899
1300
  renameTitle,
900
1301
  loadPresets: () => presets.list(),
901
1302
  switchMode: switchModeAction,
1303
+ loadPermissions: () => permissionPresets === undefined
1304
+ ? Promise.reject(new Error('permission presets are not mounted in this composition'))
1305
+ : Promise.resolve(listPermissionRows(permissionPresets)),
902
1306
  createSession,
903
1307
  loadSessions,
904
1308
  loadSessionTranscript,
1309
+ loadSubagents: () => {
1310
+ const current = session
1311
+ if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
1312
+ return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
1313
+ .then(rows => rows.filter(row => row.parent === current.id))
1314
+ },
905
1315
  switchSession,
906
1316
  cancelSessionSwitch,
907
1317
  loadPlugins: () => listPluginRows(ctx),