dsh-code 1.2.0 → 1.4.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 (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -0,0 +1,81 @@
1
+ /** Session identity resolution for one terminal runner invocation.
2
+ *
3
+ * Turns parsed startup flags into the session this process will run, and maps
4
+ * a session id onto a filename-safe export default. Resolution reads persisted
5
+ * headers, so it is the runner's only pre-composition persistence IO; the
6
+ * remaining helpers are pure.
7
+ *
8
+ * @module @deepseek-ai/dsh-code/session-target
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto'
12
+ import type { SessionId, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
13
+ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
14
+ import { isSubagentSession, matchSessionId, newestRootForCwd } from '../session/session-directory.ts'
15
+ import type { TuiStartup } from '../startup.ts'
16
+
17
+ /** The session identity this invocation will run, plus whether it is resumed. */
18
+ export interface Target {
19
+ sessionId: string
20
+ resume: boolean
21
+ mode?: string
22
+ cwd?: string
23
+ seed?: readonly SessionEvent[]
24
+ parentSession?: SessionId
25
+ /** Marks the session as a subagent conversation in the durable header. */
26
+ origin?: 'subagent'
27
+ seedLength?: number
28
+ }
29
+
30
+ /**
31
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
32
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
33
+ * user text: path separators must never leak into the default export filename
34
+ * (which would escape the session cwd).
35
+ * @param id - the session id.
36
+ * @returns at most the last 8 filename-safe characters.
37
+ */
38
+ export function exportSessionIdSuffix(id: string): string {
39
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
40
+ }
41
+
42
+ /**
43
+ * Resolve the invocation's target session against the persisted headers.
44
+ * @param startup - the parsed startup flags.
45
+ * @param persistence - the persistence service; required for resume/latest.
46
+ * @param cwd - the working directory `--continue` filters by.
47
+ * @returns the target identity.
48
+ * @throws with a user-facing message when the flags name nothing resolvable.
49
+ */
50
+ export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
51
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
52
+ if (startup.kind === 'named') {
53
+ // The id must not exist yet: reject before any Agent composition when the
54
+ // backend can tell us (a live collision is still caught by the session
55
+ // store at create time).
56
+ if (persistence !== undefined) {
57
+ const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
58
+ if (headers.some(header => header.id === startup.sessionId)) {
59
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
60
+ }
61
+ }
62
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
63
+ }
64
+ if (persistence === undefined) {
65
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
66
+ }
67
+ const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
68
+ if (startup.kind === 'resume') {
69
+ const matched = matchSessionId(headers, startup.sessionId)
70
+ // Subagent conversations are read-only everywhere else; the CLI must not
71
+ // be a back door into appending root turns to a child's durable log.
72
+ if (isSubagentSession(matched)) {
73
+ throw new Error('subagent conversations are read-only; resume a root session')
74
+ }
75
+ return { sessionId: matched.id, resume: true }
76
+ }
77
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
78
+ const newest = newestRootForCwd(headers, cwd)
79
+ if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
80
+ return { sessionId: newest.id, resume: true }
81
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Resolve the runner's startup flags from the loose plugin config.
3
+ *
4
+ * The Cordis config schema is deliberately loose (`kind` is an unvalidated
5
+ * string, narrowed here), so this is the single place that maps a config row
6
+ * onto the `TuiStartup` the runner consumes — pure, and unit-testable without
7
+ * a plugin context.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/runner/startup-config
10
+ */
11
+
12
+ import { parseThemeName } from '../theme.ts'
13
+ import type { TuiStartup } from '../startup.ts'
14
+
15
+ /** The config row shape this resolver narrows; satisfied by the plugin schema. */
16
+ export interface StartupConfigRow {
17
+ /** How this invocation obtains its session identity. */
18
+ readonly kind: string
19
+ readonly sessionId?: string
20
+ readonly mode?: string
21
+ readonly theme?: string
22
+ readonly prompt?: string
23
+ readonly images?: readonly string[]
24
+ }
25
+
26
+ /**
27
+ * Narrow one config row to a startup.
28
+ *
29
+ * `resume`/`named` without a session id degrade to a fresh launch, an invalid
30
+ * theme string narrows to the dark default (and is therefore still present),
31
+ * and `mode` only survives on the kinds that can pre-compose a session.
32
+ * @param config - the validated plugin config.
33
+ * @returns the startup the runner will resolve a target from.
34
+ */
35
+ export function resolveStartupConfig(config: { readonly startup: StartupConfigRow }): TuiStartup {
36
+ const row = config.startup
37
+ // The CLI validated --theme at parse time; the loose config schema falls
38
+ // back to dark for anything unexpected.
39
+ const theme = row.theme === undefined ? undefined : parseThemeName(row.theme)
40
+ const input = {
41
+ ...(theme === undefined ? {} : { theme }),
42
+ ...(row.prompt === undefined ? {} : { prompt: row.prompt }),
43
+ ...(row.images === undefined ? {} : { images: [...row.images] }),
44
+ }
45
+ if (row.kind === 'resume' && row.sessionId !== undefined) {
46
+ return { kind: 'resume', sessionId: row.sessionId, ...input }
47
+ }
48
+ if (row.kind === 'latest') return { kind: 'latest', ...input }
49
+ const mode = row.mode === undefined ? {} : { mode: row.mode }
50
+ if (row.kind === 'named' && row.sessionId !== undefined) {
51
+ return { kind: 'named', sessionId: row.sessionId, ...mode, ...input }
52
+ }
53
+ return { kind: 'fresh', ...mode, ...input }
54
+ }
@@ -0,0 +1,157 @@
1
+ /** Submission vocabulary and the startup input gate for the terminal runner.
2
+ *
3
+ * Pure over the handles they are given: the queue mutations and the session
4
+ * guard are decisions the runner applies, and the gate only orders deliveries,
5
+ * so every branch is testable without composing an Agent.
6
+ *
7
+ * @module @deepseek-ai/dsh-code/submissions
8
+ */
9
+
10
+ import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/dsh-llm'
11
+ import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
12
+ import type { UserMessage } from '@deepseek-ai/dsh-session'
13
+ import type { QueueMutation } from '../ui/ui-contract.ts'
14
+
15
+ /** One composer submission waiting behind the startup delivery. */
16
+ export interface QueuedSubmission {
17
+ readonly text: string
18
+ /** `steer` inserts into the running turn; `followup` waits for the next one. */
19
+ readonly mode: 'followup' | 'steer'
20
+ readonly images: readonly ContentBlock[]
21
+ }
22
+
23
+ /** What one requested queue mutation did; the runner maps it to one notice. */
24
+ export type QueueMutationOutcome =
25
+ | 'removed'
26
+ | 'edited'
27
+ | 'steered'
28
+ | 'unavailable'
29
+ | 'empty'
30
+ | 'steerUnavailable'
31
+
32
+ /**
33
+ * Replace one queued message's text while keeping its attachments. A queue
34
+ * edit rewrites what the user typed, not what they attached: image and file
35
+ * blocks ride through in delivery order (text first, then attachments, the
36
+ * shape {@link deliverLine} submits). Dropping them here would silently strip
37
+ * an attachment the user already confirmed, so this is the edit's single
38
+ * definition and the panel's read-only marker only mirrors it.
39
+ */
40
+ export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
41
+ const attachments = content.filter(block => block.type !== 'text')
42
+ return [{ type: 'text', text }, ...attachments]
43
+ }
44
+
45
+ /**
46
+ * Apply one terminal queue mutation to the live inbox. The decision and the
47
+ * inbox change are pure over the supplied handles so every branch is testable
48
+ * without an agent; steering itself is injected because it wakes the driver
49
+ * rather than mutating the inbox. The durable inbox splices remain the UI's
50
+ * single source of truth — this helper never reports a state the inbox did not
51
+ * actually reach.
52
+ * @param inbox - the live agent inbox (pending lists plus its mutators).
53
+ * @param status - the agent's lifecycle status; steering needs `running`.
54
+ * @param messageId - identity of the queued message to mutate.
55
+ * @param action - the requested mutation.
56
+ * @param steer - submits the removed message as next-step steering.
57
+ * @returns the outcome the caller reports.
58
+ */
59
+ export function applyQueueMutation(
60
+ inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
61
+ status: AgentStatus,
62
+ messageId: string,
63
+ action: QueueMutation,
64
+ steer: (message: UserMessage) => void,
65
+ ): QueueMutationOutcome {
66
+ const id = MessageId(messageId)
67
+ const message = inbox.nextTurn.find(candidate => candidate.id === id)
68
+ if (message === undefined) return 'unavailable'
69
+ switch (action.kind) {
70
+ case 'remove':
71
+ return inbox.remove(id) ? 'removed' : 'unavailable'
72
+ case 'edit':
73
+ if (action.text.trim() === '') return 'empty'
74
+ inbox.replace(id, createUserMessage({
75
+ content: queueEditContent(message.content, action.text),
76
+ source: message.source,
77
+ }))
78
+ return 'edited'
79
+ case 'steer':
80
+ if (status !== 'running') return 'steerUnavailable'
81
+ // Steer promotes the message out of next-turn, so a failing submit must
82
+ // put it back: the row the user was looking at never just disappears.
83
+ if (!inbox.remove(id)) return 'unavailable'
84
+ try {
85
+ steer(message)
86
+ } catch (error: unknown) {
87
+ inbox.append('next-turn', message)
88
+ throw error
89
+ }
90
+ return 'steered'
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Cancel the active turn while keeping the next-turn queue, then wake the
96
+ * driver again so the preserved messages actually run. `cancel` clears
97
+ * pending work by default and never wakes the driver on its own, so the queue
98
+ * is captured first and re-submitted afterwards: a waking submission latches
99
+ * the wake while the aborted activity converges to idle, which is what turns
100
+ * "preserved" into "sent next" instead of "parked forever". Next-step
101
+ * steering is deliberately dropped — it belonged to the cancelled turn.
102
+ * @param agent - the live agent handle.
103
+ * @returns how many queued messages were preserved across the abort.
104
+ */
105
+ export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
106
+ const queued = [...agent.inbox.nextTurn]
107
+ agent.cancel({ kind: 'user' })
108
+ for (const message of queued) agent.followup(message)
109
+ return queued.length
110
+ }
111
+
112
+ /**
113
+ * Whether a tagged submission still belongs to the active session. Attachment
114
+ * prepares resolve on the microtask timeline, while a queued session switch
115
+ * remounts the app asynchronously — the composing instance's unmount cleanup
116
+ * runs too late to abort, so the delivery itself carries the composing
117
+ * session's full id and the runner drops it here when the world moved on.
118
+ * An untagged (synchronous) or pending-session ('') submission always passes.
119
+ */
120
+ export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
121
+ return origin === undefined || origin === '' || origin === activeSessionId
122
+ }
123
+
124
+ /**
125
+ * Order-preserving gate for composer input while the startup prompt/images
126
+ * are still preparing. Anything submitted before the startup delivery settles
127
+ * queues and flushes afterwards in submit order, so the initial request can
128
+ * never be overtaken by typing that raced a slow image preparation. The flush
129
+ * also runs when the startup delivery fails: user input is never stranded.
130
+ */
131
+ export class StartupInputGate {
132
+ private readonly queued: QueuedSubmission[] = []
133
+ private pending = false
134
+ constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
135
+
136
+ /** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
137
+ submit(submission: QueuedSubmission): void {
138
+ if (this.pending) this.queued.push(submission)
139
+ else this.deliver(submission)
140
+ }
141
+
142
+ /**
143
+ * Run the startup delivery — the callback receives the direct-delivery sink
144
+ * for the startup prompt itself — then flush everything that queued behind
145
+ * it, in order, even when the callback rejects.
146
+ */
147
+ async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
148
+ this.pending = true
149
+ try {
150
+ await startup(submission => this.deliver(submission))
151
+ } finally {
152
+ this.pending = false
153
+ const queued = this.queued.splice(0)
154
+ for (const submission of queued) this.deliver(submission)
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One live attachment to a subagent conversation: a transcript store seeded
3
+ * from the child's durable log and then fed by the process-local event and
4
+ * stream buses in real time. The parent's own store is never touched.
5
+ *
6
+ * @module @deepseek-ai/dsh-code/session/attach
7
+ */
8
+
9
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
11
+
12
+ /** The bus surfaces one attachment reads; the runner wires these to ctx. */
13
+ export interface SubagentAttachmentServices {
14
+ /** Seed: the child's full durable event log, oldest first. */
15
+ load(id: string, signal?: AbortSignal): Promise<readonly SessionEvent[]>
16
+ /** Durable events for exactly this child session, live. */
17
+ subscribeEvents(id: string, onEvent: (event: SessionEvent) => void): () => void
18
+ /** Token stream frames for exactly this child agent, live. */
19
+ subscribeStream(id: string, onFrame: (frame: Parameters<TranscriptStore['applyStreamFrame']>[0]) => void): () => void
20
+ }
21
+
22
+ /** A live child view handed to the App while attached. */
23
+ export interface SubagentAttachment {
24
+ readonly id: string
25
+ readonly label: string
26
+ readonly store: TranscriptStore
27
+ /** Whether the durable seed has landed (frames are held back until then). */
28
+ seeded(): boolean
29
+ /** Drop the bus subscriptions; the store freezes at its last state. */
30
+ dispose(): void
31
+ }
32
+
33
+ /** An inert empty store keeps the App's external-store hook unconditional. */
34
+ export const EMPTY_ATTACH_STORE: TranscriptStore = createTranscriptStore()
35
+
36
+ /**
37
+ * Attach to one subagent conversation.
38
+ *
39
+ * Ordering contract: durable events carry a per-session sequence, so the seed
40
+ * sets a watermark and live events at or below it are dropped as duplicates;
41
+ * stream frames are held until the seed lands so a frame whose settlement
42
+ * already arrived cannot resurrect a stale tail.
43
+ */
44
+ export function createSubagentAttachment(services: SubagentAttachmentServices, id: string, label: string): SubagentAttachment {
45
+ const store = createTranscriptStore()
46
+ const controller = new AbortController()
47
+ let watermark = -1
48
+ let seeded = false
49
+ const pending: SessionEvent[] = []
50
+ const applyDurable = (event: SessionEvent): void => {
51
+ const seq = Number(event.seq ?? -1)
52
+ if (seq >= 0 && seq <= watermark) return
53
+ if (seq >= 0) watermark = seq
54
+ store.apply(event)
55
+ }
56
+ const offEvents = services.subscribeEvents(id, event => {
57
+ if (!seeded) pending.push(event)
58
+ else applyDurable(event)
59
+ })
60
+ const offFrames = services.subscribeStream(id, frame => {
61
+ if (seeded) store.applyStreamFrame(frame)
62
+ })
63
+ services.load(id, controller.signal).then(events => {
64
+ if (controller.signal.aborted) return
65
+ for (const event of events) applyDurable(event)
66
+ seeded = true
67
+ for (const event of pending) applyDurable(event)
68
+ pending.length = 0
69
+ }, () => {
70
+ // The live buses still feed the view; a failed seed leaves the watermark
71
+ // unset so nothing durable is skipped, and the store shows what arrives.
72
+ seeded = true
73
+ for (const event of pending) applyDurable(event)
74
+ pending.length = 0
75
+ })
76
+ return {
77
+ id,
78
+ label,
79
+ store,
80
+ seeded: () => seeded,
81
+ dispose: () => {
82
+ controller.abort()
83
+ offEvents()
84
+ offFrames()
85
+ },
86
+ }
87
+ }
@@ -13,13 +13,17 @@ export function selectForkSeed(events: readonly SessionEvent[], atSeq?: number):
13
13
  throw new Error('fork event sequence must be a non-negative integer')
14
14
  }
15
15
  const lastSeq = events.at(-1)?.seq ?? -1
16
- const anchored = atSeq === undefined
17
- ? undefined
18
- : events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
19
- const boundary = anchored
20
- ?? (atSeq === undefined || atSeq > lastSeq
21
- ? events.findLast(event => event.type === 'turn/end')
22
- : undefined)
16
+ let boundary: SessionEvent | undefined
17
+ if (atSeq === undefined || atSeq > lastSeq) {
18
+ boundary = events.findLast(event => event.type === 'turn/end')
19
+ } else {
20
+ // The last turn/start at or before the anchor. A seq in the gap after a
21
+ // turn/end (title, compaction) must not walk FORWARD to the next turn.
22
+ const start = events.findLast(event => event.type === 'turn/start' && event.seq <= atSeq)
23
+ boundary = start === undefined
24
+ ? undefined
25
+ : events.find(event => event.type === 'turn/end' && event.seq >= start.seq)
26
+ }
23
27
  if (boundary === undefined) {
24
28
  throw new Error(atSeq !== undefined && atSeq <= lastSeq
25
29
  ? `the turn containing event ${atSeq} has not completed`
@@ -122,6 +122,20 @@ export function beginRecall(entries: readonly string[], draft: string): RecallSt
122
122
  return { entries, index: null, savedDraft: draft, lastRecalled: null }
123
123
  }
124
124
 
125
+ /**
126
+ * Join a panel-recalled entry onto the draft already in the composer: the
127
+ * draft is extended, never replaced, so picking a history row cannot discard
128
+ * work in progress. An empty draft takes the entry as-is; otherwise the entry
129
+ * starts on its own line unless the draft already ends one.
130
+ * @param draft - the composer text before the recall.
131
+ * @param entry - the sanitized text of the accepted row.
132
+ * @returns the text to place in the composer.
133
+ */
134
+ export function appendRecall(draft: string, entry: string): string {
135
+ if (draft === '') return entry
136
+ return draft.endsWith('\n') ? draft + entry : `${draft}\n${entry}`
137
+ }
138
+
125
139
  /** The outcome of one recall step. */
126
140
  export interface RecallStep {
127
141
  state: RecallState
@@ -2,8 +2,8 @@
2
2
 
3
3
  import { basename, dirname, resolve } from 'node:path'
4
4
  import { realpathSync } from 'node:fs'
5
- import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
6
- import { t } from './i18n.ts'
5
+ import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
6
+ import { t } from '../i18n.ts'
7
7
 
8
8
  export interface SessionRecord {
9
9
  readonly header: SessionHeader
@@ -11,6 +11,52 @@ export interface SessionRecord {
11
11
  readonly persisted: boolean
12
12
  }
13
13
 
14
+ /** Minimal write handle retained while a planned deletion touches artifacts. */
15
+ export interface SessionDeletionLease {
16
+ close(): Promise<void>
17
+ }
18
+
19
+ /** Public persistence operation used to acquire the backend's write lease. */
20
+ export interface SessionDeletionPersistence {
21
+ open(id: SessionId, access: 'write'): Promise<SessionDeletionLease>
22
+ }
23
+
24
+ /**
25
+ * Acquire every subtree member's cross-process write lease before deleting
26
+ * any artifact. A partial acquisition is rolled back, so callers either hold
27
+ * the whole deletion boundary or touch nothing.
28
+ */
29
+ export async function acquireSessionDeletionLeases(
30
+ persistence: SessionDeletionPersistence,
31
+ ids: readonly string[],
32
+ ): Promise<readonly SessionDeletionLease[]> {
33
+ const leases: SessionDeletionLease[] = []
34
+ try {
35
+ for (const id of ids) leases.push(await persistence.open(SessionId(id), 'write'))
36
+ return leases
37
+ } catch (error: unknown) {
38
+ try {
39
+ await releaseSessionDeletionLeases(leases)
40
+ } catch (releaseError: unknown) {
41
+ throw new AggregateError([error, releaseError], 'session deletion lease acquisition and rollback failed')
42
+ }
43
+ throw error
44
+ }
45
+ }
46
+
47
+ /** Release deletion leases in reverse acquisition order. */
48
+ export async function releaseSessionDeletionLeases(leases: readonly SessionDeletionLease[]): Promise<void> {
49
+ const failures: unknown[] = []
50
+ for (const lease of [...leases].reverse()) {
51
+ try {
52
+ await lease.close()
53
+ } catch (error: unknown) {
54
+ failures.push(error)
55
+ }
56
+ }
57
+ if (failures.length > 0) throw new AggregateError(failures, 'failed to release session deletion leases')
58
+ }
59
+
14
60
  export interface TitleObservationResult {
15
61
  readonly sessionId: string
16
62
  readonly status: 'fulfilled' | 'rejected'
@@ -105,6 +151,26 @@ export function matchSessionId(headers: readonly SessionHeader[], wanted: string
105
151
  return matches[0]
106
152
  }
107
153
 
154
+ /**
155
+ * Unique picker-row match by exact id, unique prefix, or unique suffix.
156
+ * The resume list shows `id.slice(-12)`, so `/delete` arguments are often
157
+ * that tail rather than a leading prefix.
158
+ */
159
+ export function matchSessionRow(rows: readonly SessionRow[], wanted: string): SessionRow {
160
+ const needle = wanted.trim()
161
+ if (needle === '') throw new Error('no persisted session matches ""')
162
+ const exact = rows.filter(row => row.id === needle)
163
+ if (exact[0] !== undefined && exact.length === 1) return exact[0]
164
+ const prefixed = rows.filter(row => row.id.startsWith(needle))
165
+ if (prefixed[0] !== undefined && prefixed.length === 1) return prefixed[0]
166
+ const suffixed = rows.filter(row => row.id.endsWith(needle))
167
+ if (suffixed[0] !== undefined && suffixed.length === 1) return suffixed[0]
168
+ if (prefixed.length > 1 || suffixed.length > 1) {
169
+ throw new Error(`session prefix "${needle}" is ambiguous (${Math.max(prefixed.length, suffixed.length)} matches): use more of the id`)
170
+ }
171
+ throw new Error(`no persisted session matches "${needle}"`)
172
+ }
173
+
108
174
  /** The newest persisted ROOT session pinned to this cwd, or undefined. */
109
175
  export function newestRootForCwd(headers: readonly SessionHeader[], cwd: string): SessionHeader | undefined {
110
176
  const local = headers
@@ -151,12 +217,19 @@ export function projectSessionRows(
151
217
  preset: record.header.agentPreset ?? 'standard',
152
218
  }
153
219
  })
154
- .filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
220
+ .filter(row => sessionRowMatchesQuery(row, needle))
155
221
  .sort((left, right) => options.sort === 'newest'
156
222
  ? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
157
223
  : left.updatedAt - right.updatedAt || left.createdAt - right.createdAt)
158
224
  }
159
225
 
226
+ /** True when the picker query hits id, path, preset, or the displayed title. */
227
+ export function sessionRowMatchesQuery(row: Pick<SessionRow, 'id' | 'cwd' | 'workspace' | 'preset' | 'title'>, query: string): boolean {
228
+ const needle = query.trim().toLowerCase()
229
+ if (needle === '') return true
230
+ return `${row.id} ${row.cwd} ${row.workspace} ${row.preset} ${row.title ?? ''}`.toLowerCase().includes(needle)
231
+ }
232
+
160
233
  /** Merge page-local title observations without disturbing directory order. */
161
234
  export function mergeSessionTitles(
162
235
  rows: readonly SessionRow[],
@@ -300,7 +373,13 @@ export function jsonlSessionRoot(persistence: unknown): string | undefined {
300
373
  */
301
374
  export function collectDeletionSubtree(records: readonly SessionRecord[], id: string): string[] {
302
375
  const parentOf = new Map<string, string | undefined>()
303
- for (const record of records) parentOf.set(record.header.id, record.header.parentSession)
376
+ for (const record of records) {
377
+ // Only delegated subagents ride their parent's deletion. A fork is an
378
+ // independent conversation that merely shares lineage: deleting its
379
+ // origin must never take the branch's own log with it.
380
+ if (!isSubagentSession(record.header)) continue
381
+ parentOf.set(record.header.id, record.header.parentSession)
382
+ }
304
383
  const doomed = new Set<string>([id])
305
384
  // Iterate to a fixed point: children may be listed before their parents.
306
385
  for (let pass = 0; pass < 2; pass += 1) {
@@ -13,6 +13,7 @@ interface Request<T> {
13
13
  export class SessionSwitchQueue<T> {
14
14
  private pending: Request<T> | undefined
15
15
  private pumping = false
16
+ private running = false
16
17
 
17
18
  constructor(
18
19
  private readonly execute: (value: T) => Promise<void>,
@@ -34,6 +35,16 @@ export class SessionSwitchQueue<T> {
34
35
  return true
35
36
  }
36
37
 
38
+ /**
39
+ * Whether a queued change is being activated right now. Between the idle
40
+ * wait and the handoff the old session is still installed, so a submission
41
+ * made in that window would start a turn the handoff then discards; callers
42
+ * use this to refuse one instead of losing it.
43
+ */
44
+ get activating(): boolean {
45
+ return this.running
46
+ }
47
+
37
48
  private async pump(): Promise<void> {
38
49
  this.pumping = true
39
50
  try {
@@ -43,10 +54,13 @@ export class SessionSwitchQueue<T> {
43
54
  // Another request replaced this one while the turn was converging.
44
55
  if (this.pending !== observed) continue
45
56
  this.pending = undefined
57
+ this.running = true
46
58
  try {
47
59
  await this.execute(observed.value)
48
60
  } catch (error: unknown) {
49
61
  this.failed(error)
62
+ } finally {
63
+ this.running = false
50
64
  }
51
65
  }
52
66
  } finally {
@@ -39,7 +39,7 @@ import {
39
39
  replayProjectEvent,
40
40
  snapshotReplayView,
41
41
  type TranscriptView,
42
- } from './render/projection.ts'
42
+ } from '../render/projection.ts'
43
43
 
44
44
  /** Render frame budget: the notification cadence's upper bound. */
45
45
  const NOTIFY_FRAME_MS = 16
@@ -139,8 +139,26 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
139
139
  }
140
140
  },
141
141
  reset(): void {
142
+ // /clear wipes settled history but must not drop the live attempt map:
143
+ // chunk frames after reset still name the same attemptId, and clearing
144
+ // the map froze the stream until settlement dumped the whole reply.
145
+ const live = {
146
+ streaming: acc.streaming,
147
+ streamingReasoning: acc.streamingReasoning,
148
+ busy: acc.busy,
149
+ busySince: acc.busySince,
150
+ model: acc.model,
151
+ firstChunkAt: acc.firstChunkAt,
152
+ stepStart: acc.stepStart,
153
+ }
142
154
  acc = createReplayAccumulator()
143
- attemptKeys.clear()
155
+ acc.streaming = live.streaming
156
+ acc.streamingReasoning = live.streamingReasoning
157
+ acc.busy = live.busy
158
+ acc.busySince = live.busySince
159
+ acc.model = live.model
160
+ acc.firstChunkAt = live.firstChunkAt
161
+ acc.stepStart = live.stepStart
144
162
  dirty = true
145
163
  notify()
146
164
  },
@@ -18,7 +18,7 @@
18
18
  * microtask notify chained SyncLane rerenders past React's nested update
19
19
  * limit; a bare macrotask merge repaints a whole turn's bursts at once).
20
20
  *
21
- * @module @deepseek-ai/dsh-code/subagents
21
+ * @module @deepseek-ai/dsh-code/session/subagents
22
22
  */
23
23
 
24
24
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -227,3 +227,14 @@ export function createSubagentFeed(): SubagentFeedView & {
227
227
  },
228
228
  }
229
229
  }
230
+
231
+ /**
232
+ * Root-log catalog facts a resumed session must replay into the subagent
233
+ * feed: constructor seeds never fire on the live bus, so without this the
234
+ * children of a resumed session vanish behind a restart. The empty-child
235
+ * placeholder row (childId '') is a placeholder, not a child, and stays out.
236
+ */
237
+ export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
238
+ return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
239
+ event.type === 'subagent/catalog' && event.data.childId !== '')
240
+ }