dsh-code 0.7.0 → 0.8.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 (44) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +2685 -622
  4. package/lib/types/app.d.ts +77 -1
  5. package/lib/types/history.d.ts +15 -4
  6. package/lib/types/index.d.ts +48 -0
  7. package/lib/types/kernel-panels.d.ts +7 -0
  8. package/lib/types/permissions.d.ts +37 -0
  9. package/lib/types/presets.d.ts +2 -0
  10. package/lib/types/provider-settings.d.ts +144 -0
  11. package/lib/types/questions.d.ts +2 -0
  12. package/lib/types/render/animations.d.ts +8 -6
  13. package/lib/types/render/lines.d.ts +6 -0
  14. package/lib/types/render/markdown.d.ts +3 -3
  15. package/lib/types/render/projection.d.ts +95 -3
  16. package/lib/types/render/status.d.ts +26 -36
  17. package/lib/types/render/text.d.ts +14 -7
  18. package/lib/types/render/tool-detail.d.ts +3 -1
  19. package/lib/types/render/tool-preview.d.ts +4 -1
  20. package/lib/types/session-directory.d.ts +15 -0
  21. package/lib/types/store.d.ts +13 -2
  22. package/lib/types/version.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/src/app.ts +847 -150
  25. package/src/approval.ts +11 -2
  26. package/src/history.ts +20 -5
  27. package/src/index.ts +402 -159
  28. package/src/kernel-panels.ts +45 -8
  29. package/src/permissions.ts +85 -0
  30. package/src/presets.ts +12 -0
  31. package/src/provider-settings.ts +520 -0
  32. package/src/questions.ts +15 -5
  33. package/src/render/animations.ts +32 -18
  34. package/src/render/lines.ts +21 -6
  35. package/src/render/markdown.ts +302 -4
  36. package/src/render/projection.ts +665 -10
  37. package/src/render/status.ts +68 -162
  38. package/src/render/text.ts +28 -9
  39. package/src/render/tool-detail.ts +81 -40
  40. package/src/render/tool-preview.ts +18 -2
  41. package/src/session-directory.ts +44 -5
  42. package/src/skills.ts +8 -4
  43. package/src/store.ts +26 -8
  44. package/src/version.ts +16 -0
package/src/index.ts CHANGED
@@ -34,21 +34,39 @@ 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
36
  import { buildModelSelection, loadModelDirectory, 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'
40
47
  import { parseStatuslineItems } from './render/status.ts'
41
- import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
48
+ import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
42
49
  import { watchSkills, type SkillsView } from './skills.ts'
43
50
  import { toolArgumentsPreview } from './render/tool-preview.ts'
44
51
  import { buildExportMarkdown } from './render/export.ts'
45
52
  import type { TuiStartup } from './startup.ts'
46
53
  import { SessionSwitchQueue } from './session-switch.ts'
47
- import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
54
+ import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
55
+ import {
56
+ applyPendingPermission,
57
+ cyclePermission as cyclePermissionPreset,
58
+ effectivePermission,
59
+ listPermissionRows,
60
+ permissionPresetsFrom,
61
+ selectPermission,
62
+ } from './permissions.ts'
48
63
  import { listPluginRows } from './plugin-inventory.ts'
49
64
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
50
65
  import {
66
+ isSubagentSession,
67
+ matchSessionId,
51
68
  mergeSessionTitles,
69
+ newestRootForCwd,
52
70
  projectSessionRows,
53
71
  type SessionDirectoryOptions,
54
72
  type SessionQueryService,
@@ -112,6 +130,64 @@ interface Target {
112
130
  cwd?: string
113
131
  }
114
132
 
133
+ /**
134
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
135
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
136
+ * user text: path separators must never leak into the default export filename
137
+ * (which would escape the session cwd).
138
+ * @param id - the session id.
139
+ * @returns at most the last 8 filename-safe characters.
140
+ */
141
+ export function exportSessionIdSuffix(id: string): string {
142
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
143
+ }
144
+
145
+ /** One ordered step of the terminal quit cleanup. */
146
+ export interface QuitCleanupStep {
147
+ /** Step label used in diagnostics and tests. */
148
+ readonly name: string
149
+ /** The step's async work; a rejection is contained by the sequence. */
150
+ readonly run: () => Promise<void>
151
+ }
152
+
153
+ /**
154
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
155
+ * contained (reported through `onError`) so a failed flush or dispose never
156
+ * skips the remaining cleanup; the exit request is always reached exactly
157
+ * once.
158
+ * @param steps - the cleanup steps in dependency order (settle the visible
159
+ * session, await the final in-flight composition, await durable recall).
160
+ * @param exit - the terminal exit request (code 0).
161
+ * @param onError - optional failure sink; called once per failing step and
162
+ * itself contained, so a throwing sink cannot abort the sequence.
163
+ * @returns the names of the steps that started, in order (for tests).
164
+ */
165
+ export async function runQuitSequence(
166
+ steps: readonly QuitCleanupStep[],
167
+ exit: (code: number) => void,
168
+ onError?: (name: string, error: unknown) => void,
169
+ ): Promise<readonly string[]> {
170
+ const started: string[] = []
171
+ for (const step of steps) {
172
+ started.push(step.name)
173
+ try {
174
+ await step.run()
175
+ } catch (error) {
176
+ try {
177
+ onError?.(step.name, error)
178
+ } catch {
179
+ // The failure sink must never abort the cleanup sequence.
180
+ }
181
+ }
182
+ }
183
+ try {
184
+ exit(0)
185
+ } catch {
186
+ // The exit request itself must not become an unhandled rejection.
187
+ }
188
+ return started
189
+ }
190
+
115
191
  /**
116
192
  * Resolve the invocation's target session against the persisted headers.
117
193
  * @param startup - the parsed startup flags.
@@ -120,29 +196,37 @@ interface Target {
120
196
  * @returns the target identity.
121
197
  * @throws with a user-facing message when the flags name nothing resolvable.
122
198
  */
123
- async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
199
+ export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
124
200
  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 }
201
+ if (startup.kind === 'named') {
202
+ // The id must not exist yet: reject before any Agent composition when the
203
+ // backend can tell us (a live collision is still caught by the session
204
+ // store at create time).
205
+ if (persistence !== undefined) {
206
+ const headers: readonly SessionHeader[] = await persistence.list()
207
+ if (headers.some(header => header.id === startup.sessionId)) {
208
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
209
+ }
210
+ }
211
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
212
+ }
126
213
  if (persistence === undefined) {
127
214
  throw new Error('cannot resolve the requested session: session persistence is not configured')
128
215
  }
129
216
  const headers: readonly SessionHeader[] = await persistence.list()
130
217
  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 }
218
+ const matched = matchSessionId(headers, startup.sessionId)
219
+ // Subagent conversations are read-only everywhere else; the CLI must not
220
+ // be a back door into appending root turns to a child's durable log.
221
+ if (isSubagentSession(matched)) {
222
+ throw new Error('subagent conversations are read-only; resume a root session')
223
+ }
224
+ return { sessionId: matched.id, resume: true }
225
+ }
226
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
227
+ const newest = newestRootForCwd(headers, cwd)
228
+ if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
229
+ return { sessionId: newest.id, resume: true }
146
230
  }
147
231
 
148
232
  /**
@@ -193,6 +277,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
193
277
  const defaults = defaultModel.currentSelection()
194
278
  const presets = agentPresetsFrom(ctx)
195
279
  if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
280
+ const permissionPresets = permissionPresetsFrom(ctx)
196
281
 
197
282
  // A bare fresh launch stays transient: no Agent or session is composed, and
198
283
  // nothing is persisted, until the user's first real input. Explicit flags
@@ -219,7 +304,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
219
304
  const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
220
305
  ? {}
221
306
  : { picked: pendingSelection }
222
- let mode = next.mode
307
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode
223
308
  if (!next.resume) mode = (await presets.resolve(mode)).id
224
309
  const setup = async (agentCtx: Context): Promise<void> => {
225
310
  const sessionPreset = next.resume
@@ -240,15 +325,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
240
325
  ? await agents.resume({
241
326
  resumeSessionId: SessionId(next.sessionId),
242
327
  agentOptions: { provider: defaults.provider, model: defaults.model },
328
+ // Quit aborts an in-flight composition so the exit wait never hangs
329
+ // on a prepare that cannot settle; upstream rolls the creation back.
330
+ signal: quitAbort.signal,
243
331
  setup,
244
332
  })
245
333
  : await agents.create({
246
334
  sessionId: SessionId(next.sessionId),
247
335
  meta: { cwd: nextCwd, agentPreset: mode },
248
336
  agentOptions: { provider: defaults.provider, model: defaults.model },
337
+ signal: quitAbort.signal,
249
338
  setup,
250
339
  })
251
340
  const session = handle.agent.session
341
+ if (!next.resume && permissionPresets !== undefined) {
342
+ applyPendingPermission(permissionPresets, session, pendingPermission)
343
+ }
252
344
  const sessionCwd = session.header.cwd ?? nextCwd
253
345
  return {
254
346
  handle,
@@ -272,6 +364,44 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
272
364
  let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
273
365
  /** Explicit model pick made before any session exists (a bare launch). */
274
366
  let pendingSelection: ModelSelection | undefined
367
+ /** Agent preset selected before the first session exists. */
368
+ let pendingMode: string | undefined
369
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
370
+ let pendingModeWork: Promise<void> = Promise.resolve()
371
+ /** Permission preset selected before the first session exists. */
372
+ let pendingPermission: string | undefined
373
+ /**
374
+ * Monotonic session epoch: bumped on every successful activation, on every
375
+ * first-session creation, and on quit. Async callbacks (mention prepares,
376
+ * command executions) capture it at call time and drop their result when it
377
+ * changed, so a stale callback can never deliver to an agent that is no
378
+ * longer on screen.
379
+ */
380
+ let epoch = 0
381
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
382
+ const quitAbort = new AbortController()
383
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
384
+ const pendingControllers = new Set<AbortController>()
385
+ const abortPendingControllers = (): void => {
386
+ for (const controller of [...pendingControllers]) {
387
+ pendingControllers.delete(controller)
388
+ controller.abort()
389
+ }
390
+ }
391
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
392
+ let composing: Promise<void> | undefined
393
+ /**
394
+ * Run one session composition exclusively: concurrent compositions wait
395
+ * their turn, so a bare-launch first-session creation and a /resume
396
+ * activation can never compose agents in parallel (the loser would leak its
397
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
398
+ * always continues.
399
+ */
400
+ const compose = (work: () => Promise<void>): Promise<void> => {
401
+ const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
402
+ composing = turn.catch(() => {})
403
+ return turn
404
+ }
275
405
 
276
406
  if (!lazy) {
277
407
  const target = await resolveTarget(startup, persistence, cwd)
@@ -378,18 +508,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
378
508
  } catch {
379
509
  inputHistory = []
380
510
  }
511
+ /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
512
+ let historyWriteChain: Promise<void> = Promise.resolve()
381
513
  const recordHistory = (text: string): void => {
382
514
  if (text === '') return
383
515
  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'))
516
+ // Write the whole current list, serialized per submission: the file is
517
+ // never read back on the submit path, so rapid same-process submissions
518
+ // cannot lose entries to a read-modify-write race.
519
+ historyWriteChain = historyWriteChain
520
+ .then(() => mkdir(dirname(historyPath), { recursive: true }))
521
+ .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
393
522
  .catch((writeError: unknown) => {
394
523
  bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
395
524
  })
@@ -415,37 +544,43 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
415
544
  if (quitting) return
416
545
  quitting = true
417
546
  switchQueue.cancel()
547
+ // Stale prepares/commands die with the session they were for. Aborting
548
+ // the composition signal lets a never-settling prepare reject, so the
549
+ // exit wait below cannot hang (upstream rolls the creation back).
550
+ abortPendingControllers()
551
+ quitAbort.abort()
552
+ epoch += 1
418
553
  off()
419
554
  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
555
  const currentSession = session
423
556
  const currentActive = active
424
- if (currentSession === undefined || currentActive === undefined) {
425
- io.exit(0)
426
- return
557
+ const report = (name: string, error: unknown): void => {
558
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
427
559
  }
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) })
560
+ // One ordered cleanup: settle the visible session (if any — a bare launch
561
+ // that never composed one resolves immediately), then wait for the final
562
+ // in-flight composition (its work swallows errors and the quitting guard
563
+ // disposes any half-prepared agent), then flush the durable recall, then
564
+ // request exit. `composing` and `historyWriteChain` are read at step run
565
+ // time, so a turn that was still being queued when quit ran is included.
566
+ // A failing step must never skip the remaining cleanup.
567
+ const steps: QuitCleanupStep[] = [
568
+ ...(currentSession === undefined || currentActive === undefined
569
+ ? []
570
+ : [
571
+ { name: 'flush', run: async () => { await sessions.flush(currentSession) } },
572
+ { name: 'dispose', run: () => currentActive.handle.dispose() },
573
+ ]),
574
+ { name: 'composing', run: () => composing ?? Promise.resolve() },
575
+ { name: 'history', run: () => historyWriteChain },
576
+ ]
577
+ void runQuitSequence(steps, io.exit, report)
439
578
  }
440
579
 
441
580
  /** Run one slash line through the command registry (closed namespace). */
442
581
  const runSlash = (line: string): void => {
443
582
  const currentAgent = agent
444
583
  if (currentAgent === undefined) return
445
- if (line.startsWith('/mode ')) {
446
- void switchModeAction(line.slice(6).trim())
447
- return
448
- }
449
584
  if (line.startsWith('/resume ')) {
450
585
  requestResume(line.slice(8).trim())
451
586
  return
@@ -456,7 +591,16 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
456
591
  return
457
592
  }
458
593
  const controller = new AbortController()
594
+ const atEpoch = epoch
595
+ pendingControllers.add(controller)
596
+ const finish = (): void => {
597
+ pendingControllers.delete(controller)
598
+ }
459
599
  void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
600
+ finish()
601
+ // A switch/quit landed while the command ran: its fall-through must not
602
+ // reach an agent that is no longer on screen.
603
+ if (epoch !== atEpoch || agent !== currentAgent) return
460
604
  if (execution === undefined) {
461
605
  // No command owns this line: send it verbatim so a user-invocable
462
606
  // skill gesture (`/skill-name`) reaches the host's tool-skill
@@ -471,6 +615,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
471
615
  }
472
616
  }
473
617
  }, (error: unknown) => {
618
+ finish()
619
+ if (epoch !== atEpoch || agent !== currentAgent) return
474
620
  bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
475
621
  })
476
622
  }
@@ -493,7 +639,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
493
639
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
494
640
  return
495
641
  }
642
+ const atEpoch = epoch
496
643
  const deliver = (readable: string, context?: UserMessage): void => {
644
+ // A switch/quit landed while the snapshot was being prepared: never
645
+ // deliver to an agent that is no longer on screen.
646
+ if (epoch !== atEpoch || agent !== currentAgent) return
497
647
  // Session snapshots ride the inbox as model-facing context ahead of
498
648
  // the readable message (upstream README wiring: inject before the
499
649
  // followup/steer that wakes the driver).
@@ -519,10 +669,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
519
669
  return
520
670
  }
521
671
  const controller = new AbortController()
672
+ pendingControllers.add(controller)
522
673
  void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
674
+ pendingControllers.delete(controller)
523
675
  deliver(prepared.text, prepared.additionalContext)
524
676
  }, (error: unknown) => {
525
- if (controller.signal.aborted) return
677
+ pendingControllers.delete(controller)
678
+ if (controller.signal.aborted || epoch !== atEpoch) return
526
679
  bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
527
680
  })
528
681
  }
@@ -533,47 +686,83 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
533
686
  // failure reports and clears the queue, leaving the transient state ready
534
687
  // for the next attempt.
535
688
  const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
536
- let creating: Promise<void> | undefined
689
+ // A creation is queued/running: further submissions must not mint more
690
+ // fresh sessions (their lines queue into pendingInputs instead).
691
+ let creating = false
537
692
  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
693
+ if (creating) return
694
+ creating = true
695
+ void compose(async () => {
696
+ try {
697
+ // A direct `/mode <preset>` resolves asynchronously. Preserve submit
698
+ // order so the first composition cannot race ahead with the old mode.
699
+ await pendingModeWork
700
+ // Another composition (e.g. a /resume activated while this creation
701
+ // waited its turn) may have published a session already: deliver the
702
+ // queued lines there instead of minting a competing fresh session
703
+ // (which would orphan the live one without a dispose).
704
+ if (session !== undefined) {
705
+ const queued = pendingInputs.splice(0)
706
+ for (const item of queued) deliverLine(item.text, item.mode)
707
+ return
708
+ }
709
+ const next = await prepare({
710
+ sessionId: `session-${randomUUID()}`,
711
+ resume: false,
712
+ ...(mode === undefined ? {} : { mode }),
713
+ })
714
+ if (quitting) {
715
+ void next.handle.dispose().catch(() => {})
716
+ return
717
+ }
718
+ active = next
719
+ agent = next.agent
720
+ session = next.session
721
+ store = next.store
722
+ mentions = next.mentions
723
+ pendingMode = undefined
724
+ pendingPermission = undefined
725
+ commands.setAgent(agent)
726
+ skills.setAgent(agent)
727
+ // The App mounts with a placeholder key until the first input; the
728
+ // key-change remount below must start from a clean screen or the ghost
729
+ // static header stays visible above the new one (same source-backed
730
+ // clear the session-switch path performs).
731
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
732
+ renderCurrent()
733
+ abortPendingControllers()
734
+ epoch += 1
735
+ const queued = pendingInputs.splice(0)
736
+ for (const item of queued) deliverLine(item.text, item.mode)
737
+ } finally {
738
+ creating = false
548
739
  }
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) => {
740
+ }).catch((error: unknown) => {
565
741
  pendingInputs.length = 0
566
742
  bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
567
- }).finally(() => {
568
- creating = undefined
569
743
  })
570
- creating = attempt
571
744
  }
572
745
 
573
746
  /** Deliver one readable line to the agent, expanding session mentions first. */
574
747
  const send = (text: string, mode: 'followup' | 'steer'): void => {
575
748
  const line = text.trim()
576
749
  if (line === '') return
750
+ if (line.startsWith('/mode ')) {
751
+ void switchModeAction(line.slice(6).trim()).then(
752
+ selected => bridge.notify(`mode → ${selected}`),
753
+ error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
754
+ )
755
+ return
756
+ }
757
+ if (line.startsWith('/permission ')) {
758
+ try {
759
+ const selected = setPermissionAction(line.slice(12).trim())
760
+ bridge.notify(`permission → ${selected}`)
761
+ } catch (error: unknown) {
762
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
763
+ }
764
+ return
765
+ }
577
766
  if (session === undefined) {
578
767
  pendingInputs.push({ text: line, mode })
579
768
  ensureSession()
@@ -609,29 +798,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
609
798
  }
610
799
  }
611
800
 
801
+ /** Select one permission preset before the first session or on the active one. */
802
+ const setPermissionAction = (id: string): string => {
803
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
804
+ throw new Error('permission presets are not mounted in this composition')
805
+ }
806
+ if (id === '') throw new Error('usage: /permission <preset>')
807
+ const selected = selectPermission(permissionPresets, session, id)
808
+ if (session === undefined) {
809
+ pendingPermission = selected
810
+ renderCurrent()
811
+ }
812
+ return selected
813
+ }
814
+
612
815
  /**
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.
816
+ * Cycle to the next permission preset (Shift+Tab). Before the first session,
817
+ * the choice remains process-local and is materialized when Harness creates
818
+ * that session; afterwards the canonical service writes durable events.
616
819
  */
617
820
  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) {
821
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
627
822
  bridge.notify('permission presets are not mounted in this composition', 'warning')
628
823
  return ''
629
824
  }
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
825
  try {
634
- service.set(session, next)
826
+ const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
827
+ if (session === undefined && next !== '') {
828
+ pendingPermission = next
829
+ renderCurrent()
830
+ }
635
831
  return next
636
832
  } catch (error: unknown) {
637
833
  bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
@@ -669,7 +865,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
669
865
  }
670
866
  const wanted = argument.trim()
671
867
  const sessionCwd = session.header.cwd ?? cwd
672
- const defaultName = `dsh-session-${session.id.slice(-8)}.md`
868
+ // The default name derives from the session id, which `--session` lets the
869
+ // user spell freely: reduce it to filename-safe characters first so the
870
+ // default target can never escape the session cwd.
871
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
673
872
  const target = wanted === ''
674
873
  ? join(sessionCwd, defaultName)
675
874
  : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
@@ -723,12 +922,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
723
922
  const switchModeAction = async (id: string): Promise<string> => {
724
923
  if (id === '') throw new Error('usage: /mode <preset>')
725
924
  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')
925
+ if (currentAgent === undefined) {
926
+ const choice = pendingModeWork.then(async () => {
927
+ const preset = await selectPreset(presets, undefined, id)
928
+ // A resume may have won while this roster read was in flight; never
929
+ // leak the old pending choice into a later /new session.
930
+ if (agent === undefined) {
931
+ pendingMode = preset.id
932
+ renderCurrent()
933
+ }
934
+ return preset.id
935
+ })
936
+ pendingModeWork = choice.then(() => {}, () => {})
937
+ return choice
729
938
  }
730
- const preset = await switchPreset(presets, currentAgent, id)
731
- currentActive.mode = preset.id
939
+
940
+ const preset = await selectPreset(presets, currentAgent, id)
941
+ if (active === undefined) throw new Error('active Agent has no session state')
942
+ active.mode = preset.id
732
943
  commands.setAgent(currentAgent)
733
944
  skills.setAgent(currentAgent)
734
945
  renderCurrent()
@@ -737,52 +948,69 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
737
948
 
738
949
  interface PendingSwitch { readonly target: Target; readonly label: string }
739
950
 
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')
951
+ const activate = (nextTarget: Target): Promise<void> => {
952
+ if (quitting) return Promise.resolve()
953
+ // Serialized with every other composition (bare-launch creation, queued
954
+ // switches): at most one agent is composed at a time.
955
+ return compose(async () => {
956
+ const previous = active
957
+ const next = await prepare(nextTarget)
958
+ // Quit landed while the next session was being composed: dispose the
959
+ // half-ready agent and leave the current session untouched.
960
+ if (quitting) {
961
+ await next.handle.dispose().catch(() => {})
962
+ return
963
+ }
964
+ active = next
965
+ agent = next.agent
966
+ session = next.session
967
+ store = next.store
968
+ mentions = next.mentions
969
+ pendingMode = undefined
970
+ pendingPermission = undefined
971
+ commands.setAgent(agent)
972
+ skills.setAgent(agent)
973
+ try {
974
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
975
+ renderCurrent()
976
+ } catch (error: unknown) {
977
+ active = previous
978
+ agent = previous?.agent
979
+ session = previous?.session
980
+ store = previous === undefined ? createTranscriptStore() : previous.store
981
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
982
+ if (agent !== undefined) commands.setAgent(agent)
983
+ if (agent !== undefined) skills.setAgent(agent)
984
+ await next.handle.dispose()
985
+ if (!quitting) renderCurrent()
986
+ throw error
987
+ }
988
+ // From here the new session is live: in-flight prepares/commands for
989
+ // the previous agent are stale and must be aborted and ignored.
990
+ abortPendingControllers()
991
+ epoch += 1
992
+ // No previous session (a bare launch switched straight into a resume):
993
+ // nothing to flush or dispose, so just confirm the activation.
994
+ if (previous === undefined) {
995
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
996
+ return
997
+ }
998
+ let cleanupWarning: string | undefined
999
+ try {
1000
+ await sessions.flush(previous.session)
1001
+ } catch (error: unknown) {
1002
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
1003
+ }
1004
+ try {
1005
+ await previous.handle.dispose()
1006
+ } catch (error: unknown) {
1007
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
1008
+ }
1009
+ bridge.notify(cleanupWarning === undefined
1010
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1011
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
1012
+ cleanupWarning === undefined ? 'info' : 'warning')
1013
+ })
786
1014
  }
787
1015
 
788
1016
  const switchQueue = new SessionSwitchQueue<PendingSwitch>(
@@ -818,13 +1046,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
818
1046
  const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
819
1047
  if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
820
1048
  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')
1049
+ const matched = matches[0]!
1050
+ // Same lineage gate as the CLI --resume path and the picker.
1051
+ if (isSubagentSession(matched.header)) {
1052
+ throw new Error('subagent conversations are read-only; resume a root session')
823
1053
  }
824
- if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
1054
+ if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
825
1055
  throw new Error('that session is already live in another owner')
826
1056
  }
827
- return matches[0]!.header.id
1057
+ return matched.header.id
828
1058
  }
829
1059
 
830
1060
  const requestResume = (wanted: string): void => {
@@ -857,13 +1087,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
857
1087
  }
858
1088
 
859
1089
  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.
1090
+ // A bare launch mounts with pending/default model, mode, and permission
1091
+ // facts until the first input composes the real session. These choices stay
1092
+ // process-local and create no durable state before that composition.
864
1093
  const sessionCwd = session?.header.cwd ?? cwd
865
- const model = store.getView().model !== ''
866
- ? store.getView().model
1094
+ const currentView = store.getView()
1095
+ const model = currentView.model !== ''
1096
+ ? currentView.model
867
1097
  : pendingSelection !== undefined
868
1098
  ? `${pendingSelection.provider}/${pendingSelection.model}`
869
1099
  : `${defaults.provider}/${defaults.model}`
@@ -872,6 +1102,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
872
1102
  session?.requestHeader()?.config,
873
1103
  defaults,
874
1104
  ).reasoningEffort
1105
+ const permission = permissionPresets === undefined
1106
+ ? currentView.permission
1107
+ : effectivePermission(permissionPresets, session, pendingPermission)
875
1108
  return createElement(App, {
876
1109
  key: session?.id ?? 'pending',
877
1110
  store,
@@ -886,19 +1119,29 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
886
1119
  branch: gitBranch(sessionCwd),
887
1120
  sessionId: session === undefined ? '' : session.id.slice(-8),
888
1121
  resumed: active?.resumed ?? false,
889
- mode: active?.mode ?? '',
1122
+ mode: active?.mode ?? pendingMode ?? presets.defaultId,
1123
+ permission,
890
1124
  dispatch,
891
1125
  steer,
892
1126
  interrupt,
893
1127
  quit,
894
1128
  loadModels: () => loadModelDirectory(ctx),
1129
+ loadModelProviders: () => loadProviderSettings(ctx),
1130
+ subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1131
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1132
+ unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1133
+ removeModelProvider: target => removeProviderSettings(ctx, target),
895
1134
  loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
896
1135
  cyclePermission,
1136
+ setPermission: setPermissionAction,
897
1137
  selectModel,
898
1138
  exportTranscript,
899
1139
  renameTitle,
900
1140
  loadPresets: () => presets.list(),
901
1141
  switchMode: switchModeAction,
1142
+ loadPermissions: () => permissionPresets === undefined
1143
+ ? Promise.reject(new Error('permission presets are not mounted in this composition'))
1144
+ : Promise.resolve(listPermissionRows(permissionPresets)),
902
1145
  createSession,
903
1146
  loadSessions,
904
1147
  loadSessionTranscript,