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.
- package/README.md +201 -55
- package/README.zh.md +204 -61
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +4073 -1802
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +23 -22
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +34 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +19 -0
- package/lib/types/render/text.d.ts +27 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/lib/types/store.d.ts +2 -0
- package/package.json +16 -1
- package/src/app.ts +1367 -277
- package/src/commands.ts +15 -1
- package/src/index.ts +373 -128
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +88 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +51 -1
- package/src/render/text.ts +107 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- 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
|
|
5
|
-
*
|
|
6
|
-
* durable session, answers approval asks with a y/n bar,
|
|
7
|
-
*
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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')
|