dsh-code 0.3.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.
Files changed (46) hide show
  1. package/README.md +201 -55
  2. package/README.zh.md +204 -61
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/index.mjs +4073 -1802
  6. package/lib/startup.mjs +34 -17
  7. package/lib/types/app.d.ts +23 -22
  8. package/lib/types/commands.d.ts +2 -0
  9. package/lib/types/index.d.ts +5 -5
  10. package/lib/types/internals.d.ts +2 -0
  11. package/lib/types/kernel-panels.d.ts +23 -0
  12. package/lib/types/plugin-inventory.d.ts +11 -0
  13. package/lib/types/presets.d.ts +32 -0
  14. package/lib/types/render/export.d.ts +15 -0
  15. package/lib/types/render/inspector.d.ts +34 -0
  16. package/lib/types/render/lines.d.ts +31 -0
  17. package/lib/types/render/projection.d.ts +95 -3
  18. package/lib/types/render/status.d.ts +19 -0
  19. package/lib/types/render/text.d.ts +27 -0
  20. package/lib/types/render/tool-detail.d.ts +92 -0
  21. package/lib/types/session-directory.d.ts +54 -0
  22. package/lib/types/session-switch.d.ts +17 -0
  23. package/lib/types/skills.d.ts +2 -0
  24. package/lib/types/startup.d.ts +11 -1
  25. package/lib/types/store.d.ts +2 -0
  26. package/package.json +16 -1
  27. package/src/app.ts +1367 -277
  28. package/src/commands.ts +15 -1
  29. package/src/index.ts +373 -128
  30. package/src/internals.ts +5 -0
  31. package/src/kernel-panels.ts +254 -0
  32. package/src/plugin-inventory.ts +47 -0
  33. package/src/presets.ts +64 -0
  34. package/src/render/export.ts +81 -0
  35. package/src/render/inspector.ts +88 -0
  36. package/src/render/lines.ts +207 -0
  37. package/src/render/markdown.ts +15 -1
  38. package/src/render/projection.ts +279 -16
  39. package/src/render/status.ts +51 -1
  40. package/src/render/text.ts +107 -0
  41. package/src/render/tool-detail.ts +197 -0
  42. package/src/session-directory.ts +102 -0
  43. package/src/session-switch.ts +58 -0
  44. package/src/skills.ts +20 -7
  45. package/src/startup.ts +38 -20
  46. package/src/store.ts +8 -0
package/src/index.ts CHANGED
@@ -1,32 +1,34 @@
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
  */
12
11
 
13
12
  import { randomUUID } from 'node:crypto'
14
13
  import { readFileSync } from 'node:fs'
14
+ import { writeFile as writeFileAsync } from 'node:fs/promises'
15
15
  import { basename, join } from 'node:path'
16
16
  import { createElement } from 'react'
17
17
  import type { Context } from '@deepseek-ai/cordis'
18
18
  import z from '@deepseek-ai/schemastery'
19
19
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
20
- import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
20
+ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
21
  import type {} from '@deepseek-ai/dsh-agent-default-model'
22
22
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
23
  import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
24
24
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
25
+ // Type-only: carries the ctx.sessionTitle service merge for /title.
26
+ import type {} from '@deepseek-ai/dsh-session-title'
25
27
  // Empty type imports carry the loader Context merge for the settlement await
26
28
  // and the cmdline Context merge for the appExit host value.
27
29
  import type {} from '@deepseek-ai/cordis-plugin-loader'
28
30
  import type {} from '@deepseek-ai/dsh-cmdline'
29
- import { App } from './app.ts'
31
+ import { App, type NoticeTone } from './app.ts'
30
32
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
31
33
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
32
34
  import { internals, type TuiMount } from './internals.ts'
@@ -36,7 +38,18 @@ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
36
38
  import { createTranscriptStore } from './store.ts'
37
39
  import { watchSkills, type SkillsView } from './skills.ts'
38
40
  import { toolArgumentsPreview } from './render/tool-preview.ts'
41
+ import { buildExportMarkdown } from './render/export.ts'
39
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'
40
53
 
41
54
  /** Stable Cordis plugin name. */
42
55
  export const name = 'tui-runner'
@@ -47,13 +60,14 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
47
60
  /** Plugin config: the startup resolved from this app's injected provider service. */
48
61
  export interface Config {
49
62
  /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
50
- startup: { kind: string; sessionId?: string }
63
+ startup: { kind: string; sessionId?: string; mode?: string }
51
64
  }
52
65
 
53
66
  export const Config: z<Config> = z.object({
54
67
  startup: z.object({
55
68
  kind: z.string().required(),
56
69
  sessionId: z.string(),
70
+ mode: z.string(),
57
71
  }),
58
72
  })
59
73
 
@@ -89,6 +103,8 @@ function gitBranch(cwd: string): string {
89
103
  interface Target {
90
104
  sessionId: string
91
105
  resume: boolean
106
+ mode?: string
107
+ cwd?: string
92
108
  }
93
109
 
94
110
  /**
@@ -100,8 +116,8 @@ interface Target {
100
116
  * @throws with a user-facing message when the flags name nothing resolvable.
101
117
  */
102
118
  async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
103
- if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
104
- 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 }
105
121
  if (persistence === undefined) {
106
122
  throw new Error('cannot resolve the requested session: session persistence is not configured')
107
123
  }
@@ -145,7 +161,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
145
161
  /** The runner's connection between the React app and the process side. */
146
162
  interface AppBridge {
147
163
  /** Post one local notice line (feedback the transcript does not carry). */
148
- notify(text: string): void
164
+ notify(text: string, tone?: NoticeTone): void
149
165
  }
150
166
 
151
167
  /**
@@ -164,76 +180,92 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
164
180
  const defaultModel = ctx.get('agentDefaultModel')
165
181
  const sessions = ctx.get('sessions')
166
182
  const persistence = ctx.get('sessionPersistence')
183
+ const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
167
184
  // Early process shutdown can dispose the tree while settlement is pending.
168
185
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
169
186
 
170
187
  const cwd = process.cwd()
171
188
  const target = await resolveTarget(startup, persistence, cwd)
172
-
173
189
  const defaults = defaultModel.currentSelection()
174
- let picked: ModelSelection | undefined
175
- let session: Session
176
- let agent: Agent
177
- if (target.resume) {
178
- const resumed = await agents.resume({
179
- resumeSessionId: SessionId(target.sessionId),
180
- agentOptions: { provider: defaults.provider, model: defaults.model },
181
- setup: (agentCtx) => {
182
- // The getter order mirrors the web host's resume selection: an
183
- // in-process switch wins, then the session's own last logged request
184
- // header, then the deployment default. Without this, a resumed
185
- // session's first request would silently fall back to the default.
186
- const selection: ModelSelectionRef = {
187
- get current(): ModelSelection | undefined {
188
- if (picked !== undefined) return picked
189
- const logged = agentCtx.agent?.session.requestHeader()?.config
190
- if (logged !== undefined) {
191
- return {
192
- provider: logged.provider,
193
- model: logged.model,
194
- ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
195
- }
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 },
196
225
  }
197
- return defaults
198
- },
199
- set current(next: ModelSelection | undefined) {
200
- picked = next
201
- },
202
- assembled: undefined,
203
- }
204
- installModelSelection(agentCtx, selection)
205
- },
206
- })
207
- agent = resumed.agent
208
- session = agent.session
209
- } else {
210
- const created = await agents.create({
211
- sessionId: SessionId(target.sessionId),
212
- meta: { cwd },
213
- agentOptions: { provider: defaults.provider, model: defaults.model },
214
- setup: (agentCtx) => {
215
- // Same getter shape as resume, minus the logged-header arm (a fresh
216
- // session has no request header yet).
217
- const selection: ModelSelectionRef = {
218
- get current(): ModelSelection | undefined {
219
- return picked ?? defaults
220
- },
221
- set current(next: ModelSelection | undefined) {
222
- picked = next
223
- },
224
- assembled: undefined,
225
- }
226
- installModelSelection(agentCtx, selection)
227
- },
228
- })
229
- agent = created.agent
230
- 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
+ }
231
258
  }
232
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
+
233
266
  // Seed the transcript from the full session log: constructor seeds never
234
267
  // fire on `session/event`, so a resumed session paints its history once,
235
268
  // here, before the first render.
236
- const store = createTranscriptStore(session.events)
237
269
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
238
270
  if (subject.id === session.id) store.apply(event)
239
271
  })
@@ -257,10 +289,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
257
289
  // through this same pipe.
258
290
  const questions: QuestionStore = mountQuestionProvider(ctx)
259
291
 
260
- // @mention support: workspace file scan plus the opt-in session-reference
261
- // service (the patch mounts it); submission expands session mentions.
262
- const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
263
-
264
292
  // The bridge the React app registers on mount: local notices from the
265
293
  // process side (unknown commands, switch confirmations, cancels).
266
294
  const bridge: AppBridge = { notify: () => {} }
@@ -272,6 +300,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
272
300
  const quit = (): void => {
273
301
  if (quitting) return
274
302
  quitting = true
303
+ switchQueue.cancel()
275
304
  off()
276
305
  mountRef.current?.unmount()
277
306
  void sessions.flush(session)
@@ -280,29 +309,45 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
280
309
  // must not trap the user in a dead terminal, so report and still exit.
281
310
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
282
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
+ })
283
316
  .then(() => { io.exit(0) })
284
317
  }
285
318
 
286
319
  /** Run one slash line through the command registry (closed namespace). */
287
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
+ }
288
329
  const registry = ctx.get('commands')
289
330
  if (registry === undefined) {
290
- bridge.notify('no command registry is mounted in this composition')
331
+ bridge.notify('no command registry is mounted in this composition', 'error')
291
332
  return
292
333
  }
293
334
  const controller = new AbortController()
294
- void registry.execute(agent, line, controller.signal).then((execution) => {
335
+ void Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
295
336
  if (execution === undefined) {
296
337
  // No command owns this line: send it verbatim so a user-invocable
297
338
  // skill gesture (`/skill-name`) reaches the host's tool-skill
298
339
  // pre-step injection — the web composer's same fall-through.
299
- agent.followup(createUserMessage({
300
- content: [{ type: 'text', text: line }],
301
- source: { kind: 'user' },
302
- }))
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
+ }
303
348
  }
304
349
  }, (error: unknown) => {
305
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
350
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
306
351
  })
307
352
  }
308
353
 
@@ -321,23 +366,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
321
366
  try {
322
367
  parsed = mentions.parse(line)
323
368
  } catch (error: unknown) {
324
- 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')
325
370
  return
326
371
  }
327
372
  const deliver = (readable: string, context?: UserMessage): void => {
328
373
  // Session snapshots ride the inbox as model-facing context ahead of
329
374
  // the readable message (upstream README wiring: inject before the
330
375
  // followup/steer that wakes the driver).
331
- if (context !== undefined) agent.inject(context)
332
- const message = createUserMessage({
333
- content: [{ type: 'text', text: readable }],
334
- source: { kind: 'user' },
335
- })
336
- if (mode === 'steer') {
337
- agent.steer(message)
338
- bridge.notify('steering queued — the next step sees it')
339
- } else {
340
- 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')
341
390
  }
342
391
  }
343
392
  if (parsed.references.length === 0) {
@@ -349,7 +398,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
349
398
  deliver(prepared.text, prepared.additionalContext)
350
399
  }, (error: unknown) => {
351
400
  if (controller.signal.aborted) return
352
- 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')
353
402
  })
354
403
  }
355
404
 
@@ -370,9 +419,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
370
419
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
371
420
  const interrupt = (): boolean => {
372
421
  if (agent.status !== 'running') return false
373
- agent.cancel({ kind: 'user' })
374
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
375
- 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
+ }
376
430
  }
377
431
 
378
432
  /**
@@ -389,49 +443,240 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
389
443
  }
390
444
  | undefined
391
445
  if (service === undefined || service.names.length === 0) {
392
- bridge.notify('permission presets are not mounted in this composition')
446
+ bridge.notify('permission presets are not mounted in this composition', 'warning')
393
447
  return ''
394
448
  }
395
449
  const at = service.names.indexOf(service.current(session.events))
396
450
  const next = service.names[(at + 1) % service.names.length] ?? ''
397
451
  if (next === '') return ''
398
- service.set(session, next)
399
- 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
+ }
400
459
  }
401
460
 
402
461
  /** Apply one /model selection: takes effect from the next assembled step. */
403
462
  const selectModel = (row: ModelRow): string => {
404
- picked = { provider: row.provider, model: row.model }
463
+ active.selection.picked = { provider: row.provider, model: row.model }
405
464
  return `${row.provider}/${row.model}`
406
465
  }
407
466
 
408
- const initialModel = store.getView().model !== ''
409
- ? store.getView().model
410
- : `${defaults.provider}/${defaults.model}`
411
-
412
- mountRef.current = io.mount(createElement(App, {
413
- store,
414
- approval,
415
- questions,
416
- commands,
417
- skills,
418
- model: initialModel,
419
- cwd: basename(cwd),
420
- branch: gitBranch(cwd),
421
- sessionId: session.id.slice(-8),
422
- resumed: target.resume,
423
- dispatch,
424
- steer,
425
- interrupt,
426
- quit,
427
- loadModels: () => loadModelDirectory(ctx),
428
- loadMentions: mentions.candidates,
429
- cyclePermission,
430
- selectModel,
431
- onBridgeReady: (instance: AppBridge) => {
432
- bridge.notify = instance.notify
433
- },
434
- }))
467
+ /**
468
+ * Export the folded transcript to a markdown file (/export). The default
469
+ * target sits beside the session's cwd so the file lands in the user's
470
+ * workspace; an absolute or cwd-relative argument overrides it.
471
+ */
472
+ const exportTranscript = async (argument: string): Promise<void> => {
473
+ const wanted = argument.trim()
474
+ const sessionCwd = session.header.cwd ?? cwd
475
+ const defaultName = `dsh-session-${session.id.slice(-8)}.md`
476
+ const target = wanted === ''
477
+ ? join(sessionCwd, defaultName)
478
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
479
+ ? wanted
480
+ : join(sessionCwd, wanted)
481
+ const markdown = buildExportMarkdown(store.getView(), session.id)
482
+ try {
483
+ await writeFileAsync(target, `${markdown}\n`, 'utf8')
484
+ bridge.notify(`exported to ${target}`)
485
+ } catch (error: unknown) {
486
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Rename the session (/title): a user title pins the session and stops
492
+ * automatic generation (the service's own contract). The appended
493
+ * `session/title` event flows back through the store into the status line.
494
+ */
495
+ const renameTitle = (argument: string): string => {
496
+ const title = argument.trim()
497
+ if (title === '') return 'usage: /title <text>'
498
+ const service = ctx.get('sessionTitle')
499
+ if (service === undefined) return 'session titles are unavailable in this profile'
500
+ try {
501
+ service.rename(session, title)
502
+ return `title → ${title}`
503
+ } catch (error: unknown) {
504
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`
505
+ }
506
+ }
507
+
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())
435
680
  }
436
681
 
437
682
  /**
@@ -446,8 +691,8 @@ export function apply(ctx: Context, config: Config): void {
446
691
  : config.startup.kind === 'latest'
447
692
  ? { kind: 'latest' }
448
693
  : config.startup.kind === 'named' && config.startup.sessionId !== undefined
449
- ? { kind: 'named', sessionId: config.startup.sessionId }
450
- : { 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 } }
451
696
  // Read through the global service store, not the property proxy: appExit is
452
697
  // an optional host value, never an injected dependency.
453
698
  const exit = ctx.get('appExit')