dsh-code 0.4.0 → 0.5.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.
package/src/index.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
3
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
4
- * creates (or resumes) one Agent through the core registry, mounts the Ink
5
- * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
6
- * durable session, answers approval asks with a y/n bar, dispatches slash
7
- * commands through the shared registry, and on quit flushes and requests
8
- * process exit.
4
+ * creates or resumes preset-composed Agents through the core registry, keeps
5
+ * one Ink owner while the active session changes, folds submitted prompts
6
+ * into the selected durable session, answers approval asks with a y/n bar,
7
+ * dispatches slash commands, and on quit flushes and requests process exit.
9
8
  *
10
9
  * @module @deepseek-ai/dsh-code
11
10
  */
@@ -18,7 +17,7 @@ import { createElement } from 'react'
18
17
  import type { Context } from '@deepseek-ai/cordis'
19
18
  import z from '@deepseek-ai/schemastery'
20
19
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
- import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
20
+ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
21
  import type {} from '@deepseek-ai/dsh-agent-default-model'
23
22
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
24
23
  import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
@@ -29,7 +28,7 @@ import type {} from '@deepseek-ai/dsh-session-title'
29
28
  // and the cmdline Context merge for the appExit host value.
30
29
  import type {} from '@deepseek-ai/cordis-plugin-loader'
31
30
  import type {} from '@deepseek-ai/dsh-cmdline'
32
- import { App } from './app.ts'
31
+ import { App, type NoticeTone } from './app.ts'
33
32
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
34
33
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
35
34
  import { internals, type TuiMount } from './internals.ts'
@@ -41,6 +40,16 @@ import { watchSkills, type SkillsView } from './skills.ts'
41
40
  import { toolArgumentsPreview } from './render/tool-preview.ts'
42
41
  import { buildExportMarkdown } from './render/export.ts'
43
42
  import type { TuiStartup } from './startup.ts'
43
+ import { SessionSwitchQueue } from './session-switch.ts'
44
+ import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
45
+ import { listPluginRows } from './plugin-inventory.ts'
46
+ import {
47
+ mergeSessionTitles,
48
+ projectSessionRows,
49
+ type SessionDirectoryOptions,
50
+ type SessionQueryService,
51
+ type SessionRow,
52
+ } from './session-directory.ts'
44
53
 
45
54
  /** Stable Cordis plugin name. */
46
55
  export const name = 'tui-runner'
@@ -51,13 +60,14 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
51
60
  /** Plugin config: the startup resolved from this app's injected provider service. */
52
61
  export interface Config {
53
62
  /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
54
- startup: { kind: string; sessionId?: string }
63
+ startup: { kind: string; sessionId?: string; mode?: string }
55
64
  }
56
65
 
57
66
  export const Config: z<Config> = z.object({
58
67
  startup: z.object({
59
68
  kind: z.string().required(),
60
69
  sessionId: z.string(),
70
+ mode: z.string(),
61
71
  }),
62
72
  })
63
73
 
@@ -93,6 +103,8 @@ function gitBranch(cwd: string): string {
93
103
  interface Target {
94
104
  sessionId: string
95
105
  resume: boolean
106
+ mode?: string
107
+ cwd?: string
96
108
  }
97
109
 
98
110
  /**
@@ -104,8 +116,8 @@ interface Target {
104
116
  * @throws with a user-facing message when the flags name nothing resolvable.
105
117
  */
106
118
  async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
107
- if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
108
- if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
119
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
120
+ if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
109
121
  if (persistence === undefined) {
110
122
  throw new Error('cannot resolve the requested session: session persistence is not configured')
111
123
  }
@@ -149,7 +161,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
149
161
  /** The runner's connection between the React app and the process side. */
150
162
  interface AppBridge {
151
163
  /** Post one local notice line (feedback the transcript does not carry). */
152
- notify(text: string): void
164
+ notify(text: string, tone?: NoticeTone): void
153
165
  }
154
166
 
155
167
  /**
@@ -168,76 +180,92 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
168
180
  const defaultModel = ctx.get('agentDefaultModel')
169
181
  const sessions = ctx.get('sessions')
170
182
  const persistence = ctx.get('sessionPersistence')
183
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
171
184
  // Early process shutdown can dispose the tree while settlement is pending.
172
185
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
173
186
 
174
187
  const cwd = process.cwd()
175
188
  const target = await resolveTarget(startup, persistence, cwd)
176
-
177
189
  const defaults = defaultModel.currentSelection()
178
- let picked: ModelSelection | undefined
179
- let session: Session
180
- let agent: Agent
181
- if (target.resume) {
182
- const resumed = await agents.resume({
183
- resumeSessionId: SessionId(target.sessionId),
184
- agentOptions: { provider: defaults.provider, model: defaults.model },
185
- setup: (agentCtx) => {
186
- // The getter order mirrors the web host's resume selection: an
187
- // in-process switch wins, then the session's own last logged request
188
- // header, then the deployment default. Without this, a resumed
189
- // session's first request would silently fall back to the default.
190
- const selection: ModelSelectionRef = {
191
- get current(): ModelSelection | undefined {
192
- if (picked !== undefined) return picked
193
- const logged = agentCtx.agent?.session.requestHeader()?.config
194
- if (logged !== undefined) {
195
- return {
196
- provider: logged.provider,
197
- model: logged.model,
198
- ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
199
- }
190
+ const presets = agentPresetsFrom(ctx)
191
+ if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
192
+
193
+ interface ActiveSession {
194
+ handle: AgentHandle
195
+ agent: Agent
196
+ session: Session
197
+ store: ReturnType<typeof createTranscriptStore>
198
+ mentions: MentionsApi
199
+ mode: string
200
+ selection: { picked?: ModelSelection }
201
+ resumed: boolean
202
+ }
203
+
204
+ /** Prepare a complete next session before disturbing the currently visible one. */
205
+ const prepare = async (next: Target): Promise<ActiveSession> => {
206
+ const nextCwd = next.cwd ?? cwd
207
+ const selectionState: { picked?: ModelSelection } = {}
208
+ let mode = next.mode
209
+ if (!next.resume) mode = (await presets.resolve(mode)).id
210
+ const setup = async (agentCtx: Context): Promise<void> => {
211
+ const sessionPreset = next.resume
212
+ ? resolvePreset(agentCtx.agent!.session)
213
+ : mode
214
+ const mounted = await presets.mount(agentCtx, sessionPreset)
215
+ mode = mounted.id
216
+ const selection: ModelSelectionRef = {
217
+ get current(): ModelSelection | undefined {
218
+ if (selectionState.picked !== undefined) return selectionState.picked
219
+ const logged = agentCtx.agent?.session.requestHeader()?.config
220
+ if (logged !== undefined) {
221
+ return {
222
+ provider: logged.provider,
223
+ model: logged.model,
224
+ ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
200
225
  }
201
- return defaults
202
- },
203
- set current(next: ModelSelection | undefined) {
204
- picked = next
205
- },
206
- assembled: undefined,
207
- }
208
- installModelSelection(agentCtx, selection)
209
- },
210
- })
211
- agent = resumed.agent
212
- session = agent.session
213
- } else {
214
- const created = await agents.create({
215
- sessionId: SessionId(target.sessionId),
216
- meta: { cwd },
217
- agentOptions: { provider: defaults.provider, model: defaults.model },
218
- setup: (agentCtx) => {
219
- // Same getter shape as resume, minus the logged-header arm (a fresh
220
- // session has no request header yet).
221
- const selection: ModelSelectionRef = {
222
- get current(): ModelSelection | undefined {
223
- return picked ?? defaults
224
- },
225
- set current(next: ModelSelection | undefined) {
226
- picked = next
227
- },
228
- assembled: undefined,
229
- }
230
- installModelSelection(agentCtx, selection)
231
- },
232
- })
233
- agent = created.agent
234
- session = agent.session
226
+ }
227
+ return defaults
228
+ },
229
+ set current(value: ModelSelection | undefined) { selectionState.picked = value },
230
+ assembled: undefined,
231
+ }
232
+ installModelSelection(agentCtx, selection)
233
+ }
234
+ const handle = next.resume
235
+ ? await agents.resume({
236
+ resumeSessionId: SessionId(next.sessionId),
237
+ agentOptions: { provider: defaults.provider, model: defaults.model },
238
+ setup,
239
+ })
240
+ : await agents.create({
241
+ sessionId: SessionId(next.sessionId),
242
+ meta: { cwd: nextCwd, agentPreset: mode },
243
+ agentOptions: { provider: defaults.provider, model: defaults.model },
244
+ setup,
245
+ })
246
+ const session = handle.agent.session
247
+ const sessionCwd = session.header.cwd ?? nextCwd
248
+ return {
249
+ handle,
250
+ agent: handle.agent,
251
+ session,
252
+ store: createTranscriptStore(session.events),
253
+ mentions: createMentions(ctx, handle.agent, sessionCwd),
254
+ mode: mode ?? 'standard',
255
+ selection: selectionState,
256
+ resumed: next.resume,
257
+ }
235
258
  }
236
259
 
260
+ let active = await prepare(target)
261
+ let agent = active.agent
262
+ let session = active.session
263
+ let store = active.store
264
+ let mentions = active.mentions
265
+
237
266
  // Seed the transcript from the full session log: constructor seeds never
238
267
  // fire on `session/event`, so a resumed session paints its history once,
239
268
  // here, before the first render.
240
- const store = createTranscriptStore(session.events)
241
269
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
242
270
  if (subject.id === session.id) store.apply(event)
243
271
  })
@@ -261,10 +289,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
261
289
  // through this same pipe.
262
290
  const questions: QuestionStore = mountQuestionProvider(ctx)
263
291
 
264
- // @mention support: workspace file scan plus the opt-in session-reference
265
- // service (the patch mounts it); submission expands session mentions.
266
- const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
267
-
268
292
  // The bridge the React app registers on mount: local notices from the
269
293
  // process side (unknown commands, switch confirmations, cancels).
270
294
  const bridge: AppBridge = { notify: () => {} }
@@ -276,6 +300,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
276
300
  const quit = (): void => {
277
301
  if (quitting) return
278
302
  quitting = true
303
+ switchQueue.cancel()
279
304
  off()
280
305
  mountRef.current?.unmount()
281
306
  void sessions.flush(session)
@@ -284,29 +309,45 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
284
309
  // must not trap the user in a dead terminal, so report and still exit.
285
310
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
286
311
  })
312
+ .then(() => active.handle.dispose())
313
+ .catch((disposeError: unknown) => {
314
+ internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
315
+ })
287
316
  .then(() => { io.exit(0) })
288
317
  }
289
318
 
290
319
  /** Run one slash line through the command registry (closed namespace). */
291
320
  const runSlash = (line: string): void => {
321
+ if (line.startsWith('/mode ')) {
322
+ void switchModeAction(line.slice(6).trim())
323
+ return
324
+ }
325
+ if (line.startsWith('/resume ')) {
326
+ requestResume(line.slice(8).trim())
327
+ return
328
+ }
292
329
  const registry = ctx.get('commands')
293
330
  if (registry === undefined) {
294
- bridge.notify('no command registry is mounted in this composition')
331
+ bridge.notify('no command registry is mounted in this composition', 'error')
295
332
  return
296
333
  }
297
334
  const controller = new AbortController()
298
- void registry.execute(agent, line, controller.signal).then((execution) => {
335
+ void Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
299
336
  if (execution === undefined) {
300
337
  // No command owns this line: send it verbatim so a user-invocable
301
338
  // skill gesture (`/skill-name`) reaches the host's tool-skill
302
339
  // pre-step injection — the web composer's same fall-through.
303
- agent.followup(createUserMessage({
304
- content: [{ type: 'text', text: line }],
305
- source: { kind: 'user' },
306
- }))
340
+ try {
341
+ agent.followup(createUserMessage({
342
+ content: [{ type: 'text', text: line }],
343
+ source: { kind: 'user' },
344
+ }))
345
+ } catch (error: unknown) {
346
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
347
+ }
307
348
  }
308
349
  }, (error: unknown) => {
309
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
350
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
310
351
  })
311
352
  }
312
353
 
@@ -325,23 +366,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
325
366
  try {
326
367
  parsed = mentions.parse(line)
327
368
  } catch (error: unknown) {
328
- bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`)
369
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
329
370
  return
330
371
  }
331
372
  const deliver = (readable: string, context?: UserMessage): void => {
332
373
  // Session snapshots ride the inbox as model-facing context ahead of
333
374
  // the readable message (upstream README wiring: inject before the
334
375
  // followup/steer that wakes the driver).
335
- if (context !== undefined) agent.inject(context)
336
- const message = createUserMessage({
337
- content: [{ type: 'text', text: readable }],
338
- source: { kind: 'user' },
339
- })
340
- if (mode === 'steer') {
341
- agent.steer(message)
342
- bridge.notify('steering queued — the next step sees it')
343
- } else {
344
- agent.followup(message)
376
+ try {
377
+ if (context !== undefined) agent.inject(context)
378
+ const message = createUserMessage({
379
+ content: [{ type: 'text', text: readable }],
380
+ source: { kind: 'user' },
381
+ })
382
+ if (mode === 'steer') {
383
+ agent.steer(message)
384
+ bridge.notify('steering queued — the next step sees it')
385
+ } else {
386
+ agent.followup(message)
387
+ }
388
+ } catch (error: unknown) {
389
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
345
390
  }
346
391
  }
347
392
  if (parsed.references.length === 0) {
@@ -353,7 +398,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
353
398
  deliver(prepared.text, prepared.additionalContext)
354
399
  }, (error: unknown) => {
355
400
  if (controller.signal.aborted) return
356
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`)
401
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
357
402
  })
358
403
  }
359
404
 
@@ -374,9 +419,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
374
419
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
375
420
  const interrupt = (): boolean => {
376
421
  if (agent.status !== 'running') return false
377
- agent.cancel({ kind: 'user' })
378
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
379
- return true
422
+ try {
423
+ agent.cancel({ kind: 'user' })
424
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
425
+ return true
426
+ } catch (error: unknown) {
427
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
428
+ return false
429
+ }
380
430
  }
381
431
 
382
432
  /**
@@ -393,19 +443,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
393
443
  }
394
444
  | undefined
395
445
  if (service === undefined || service.names.length === 0) {
396
- bridge.notify('permission presets are not mounted in this composition')
446
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
397
447
  return ''
398
448
  }
399
449
  const at = service.names.indexOf(service.current(session.events))
400
450
  const next = service.names[(at + 1) % service.names.length] ?? ''
401
451
  if (next === '') return ''
402
- service.set(session, next)
403
- return next
452
+ try {
453
+ service.set(session, next)
454
+ return next
455
+ } catch (error: unknown) {
456
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
457
+ return ''
458
+ }
404
459
  }
405
460
 
406
461
  /** Apply one /model selection: takes effect from the next assembled step. */
407
462
  const selectModel = (row: ModelRow): string => {
408
- picked = { provider: row.provider, model: row.model }
463
+ active.selection.picked = { provider: row.provider, model: row.model }
409
464
  return `${row.provider}/${row.model}`
410
465
  }
411
466
 
@@ -416,18 +471,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
416
471
  */
417
472
  const exportTranscript = async (argument: string): Promise<void> => {
418
473
  const wanted = argument.trim()
474
+ const sessionCwd = session.header.cwd ?? cwd
419
475
  const defaultName = `dsh-session-${session.id.slice(-8)}.md`
420
476
  const target = wanted === ''
421
- ? join(cwd, defaultName)
477
+ ? join(sessionCwd, defaultName)
422
478
  : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
423
479
  ? wanted
424
- : join(cwd, wanted)
480
+ : join(sessionCwd, wanted)
425
481
  const markdown = buildExportMarkdown(store.getView(), session.id)
426
482
  try {
427
483
  await writeFileAsync(target, `${markdown}\n`, 'utf8')
428
484
  bridge.notify(`exported to ${target}`)
429
485
  } catch (error: unknown) {
430
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`)
486
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
431
487
  }
432
488
  }
433
489
 
@@ -449,35 +505,178 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
449
505
  }
450
506
  }
451
507
 
452
- const initialModel = store.getView().model !== ''
453
- ? store.getView().model
454
- : `${defaults.provider}/${defaults.model}`
455
-
456
- mountRef.current = io.mount(createElement(App, {
457
- store,
458
- approval,
459
- questions,
460
- commands,
461
- skills,
462
- model: initialModel,
463
- cwd: basename(cwd),
464
- branch: gitBranch(cwd),
465
- sessionId: session.id.slice(-8),
466
- resumed: target.resume,
467
- dispatch,
468
- steer,
469
- interrupt,
470
- quit,
471
- loadModels: () => loadModelDirectory(ctx),
472
- loadMentions: mentions.candidates,
473
- cyclePermission,
474
- selectModel,
475
- exportTranscript,
476
- renameTitle,
477
- onBridgeReady: (instance: AppBridge) => {
478
- bridge.notify = instance.notify
479
- },
480
- }))
508
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
509
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
510
+ const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
511
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
512
+ // navigation/filter changes trigger a fresh, cancellable observation.
513
+ const page = projected.slice(0, 32)
514
+ if (page.length === 0) return projected
515
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
516
+ return mergeSessionTitles(projected, observations)
517
+ }
518
+
519
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
520
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
521
+ const snapshot = await sessionQuery.readSession(id, signal)
522
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
523
+ }
524
+
525
+ const switchModeAction = async (id: string): Promise<string> => {
526
+ if (id === '') throw new Error('usage: /mode <preset>')
527
+ const preset = await switchPreset(presets, agent, id)
528
+ active.mode = preset.id
529
+ commands.setAgent(agent)
530
+ skills.setAgent(agent)
531
+ renderCurrent()
532
+ return preset.id
533
+ }
534
+
535
+ interface PendingSwitch { readonly target: Target; readonly label: string }
536
+
537
+ const activate = async (nextTarget: Target): Promise<void> => {
538
+ const previous = active
539
+ const next = await prepare(nextTarget)
540
+ active = next
541
+ agent = next.agent
542
+ session = next.session
543
+ store = next.store
544
+ mentions = next.mentions
545
+ commands.setAgent(agent)
546
+ skills.setAgent(agent)
547
+ try {
548
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
549
+ renderCurrent()
550
+ } catch (error: unknown) {
551
+ active = previous
552
+ agent = previous.agent
553
+ session = previous.session
554
+ store = previous.store
555
+ mentions = previous.mentions
556
+ commands.setAgent(agent)
557
+ skills.setAgent(agent)
558
+ await next.handle.dispose()
559
+ renderCurrent()
560
+ throw error
561
+ }
562
+ let cleanupWarning: string | undefined
563
+ try {
564
+ await sessions.flush(previous.session)
565
+ } catch (error: unknown) {
566
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
567
+ }
568
+ try {
569
+ await previous.handle.dispose()
570
+ } catch (error: unknown) {
571
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
572
+ }
573
+ bridge.notify(cleanupWarning === undefined
574
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
575
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
576
+ cleanupWarning === undefined ? 'info' : 'warning')
577
+ }
578
+
579
+ const switchQueue = new SessionSwitchQueue<PendingSwitch>(
580
+ async request => { if (!quitting) await activate(request.target) },
581
+ error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
582
+ )
583
+
584
+ const requestSwitch = (request: PendingSwitch): void => {
585
+ if (request.target.sessionId === session.id) {
586
+ bridge.notify('that session is already active', 'warning')
587
+ return
588
+ }
589
+ const outcome = switchQueue.request(agent, request)
590
+ if (outcome === 'queued') {
591
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
592
+ }
593
+ }
594
+
595
+ const resolveResumeId = async (wanted: string): Promise<string> => {
596
+ if (wanted === '') throw new Error('usage: /resume <id|prefix>')
597
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
598
+ const records = await sessionQuery.listSessions()
599
+ const exact = records.filter(record => record.header.id === wanted)
600
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
601
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
602
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
603
+ if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
604
+ throw new Error('subagent conversations are read-only in /resume; resume a root session')
605
+ }
606
+ if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
607
+ throw new Error('that session is already live in another owner')
608
+ }
609
+ return matches[0]!.header.id
610
+ }
611
+
612
+ const requestResume = (wanted: string): void => {
613
+ void resolveResumeId(wanted).then(id => {
614
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
615
+ }, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
616
+ }
617
+
618
+ const createSession = (mode?: string): void => {
619
+ const nextCwd = session.header.cwd ?? cwd
620
+ const id = `session-${randomUUID()}`
621
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
622
+ }
623
+
624
+ const switchSession = (row: SessionRow): void => {
625
+ if (!row.resumable) {
626
+ bridge.notify('subagent conversations are read-only', 'warning')
627
+ return
628
+ }
629
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
630
+ }
631
+
632
+ const cancelSessionSwitch = (): boolean => {
633
+ return switchQueue.cancel()
634
+ }
635
+
636
+ const appElement = (): ReturnType<typeof createElement> => {
637
+ const sessionCwd = session.header.cwd ?? cwd
638
+ const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
639
+ return createElement(App, {
640
+ key: session.id,
641
+ store,
642
+ approval,
643
+ questions,
644
+ commands,
645
+ skills,
646
+ model,
647
+ cwd: basename(sessionCwd),
648
+ workspaceRoot: sessionCwd,
649
+ branch: gitBranch(sessionCwd),
650
+ sessionId: session.id.slice(-8),
651
+ resumed: active.resumed,
652
+ mode: active.mode,
653
+ dispatch,
654
+ steer,
655
+ interrupt,
656
+ quit,
657
+ loadModels: () => loadModelDirectory(ctx),
658
+ loadMentions: mentions.candidates,
659
+ cyclePermission,
660
+ selectModel,
661
+ exportTranscript,
662
+ renameTitle,
663
+ loadPresets: () => presets.list(),
664
+ switchMode: switchModeAction,
665
+ createSession,
666
+ loadSessions,
667
+ loadSessionTranscript,
668
+ switchSession,
669
+ cancelSessionSwitch,
670
+ loadPlugins: () => listPluginRows(ctx),
671
+ onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
672
+ })
673
+ }
674
+
675
+ const renderCurrent = (): void => {
676
+ mountRef.current?.rerender(appElement())
677
+ }
678
+
679
+ mountRef.current = io.mount(appElement())
481
680
  }
482
681
 
483
682
  /**
@@ -492,8 +691,8 @@ export function apply(ctx: Context, config: Config): void {
492
691
  : config.startup.kind === 'latest'
493
692
  ? { kind: 'latest' }
494
693
  : config.startup.kind === 'named' && config.startup.sessionId !== undefined
495
- ? { kind: 'named', sessionId: config.startup.sessionId }
496
- : { kind: 'fresh' }
694
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
695
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
497
696
  // Read through the global service store, not the property proxy: appExit is
498
697
  // an optional host value, never an injected dependency.
499
698
  const exit = ctx.get('appExit')
package/src/internals.ts CHANGED
@@ -11,6 +11,8 @@ import type { ReactElement } from 'react'
11
11
 
12
12
  /** A mounted terminal app instance; the runner owns unmount ordering. */
13
13
  export interface TuiMount {
14
+ /** Replace the root element while preserving Ink's single terminal owner. */
15
+ rerender(element: ReactElement): void
14
16
  /** Tear the terminal app down before flush and exit. */
15
17
  unmount(): void
16
18
  }
@@ -28,6 +30,9 @@ export const internals: {
28
30
  mount: (element: ReactElement): TuiMount => {
29
31
  const instance = render(element)
30
32
  return {
33
+ rerender(element: ReactElement): void {
34
+ instance.rerender(element)
35
+ },
31
36
  unmount(): void {
32
37
  instance.unmount()
33
38
  },