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,168 @@
1
+ /**
2
+ * Load-time DeepSeek Harness version gate.
3
+ *
4
+ * The terminal driver is validated against exactly one Harness snapshot, and
5
+ * the host resolves every bare `@deepseek-ai/*` import the plugin makes
6
+ * against ITS OWN installed copies (the profile's `HostResolvedRootInclude`
7
+ * redirects bare names to the installed-host base). No install-time or
8
+ * load-time check exists anywhere on that path, so a user who updates `dsh`
9
+ * would otherwise run this build against unvetted upstream code silently.
10
+ *
11
+ * This gate mirrors how the host itself reads its identity: the running CLI
12
+ * entry (`process.argv[1]`) sits inside the `@deepseek-ai/dsh` package, whose
13
+ * manifest version is released in lockstep with the Harness packages bundled
14
+ * beside it. When the probe can identify the host it compares the running
15
+ * versions against the one this build declares and fails fast with an
16
+ * actionable message; when it cannot identify the host it stays silent rather
17
+ * than brick a supported embedding on a false positive.
18
+ *
19
+ * @module @deepseek-ai/dsh-code/runner/harness-gate
20
+ */
21
+
22
+ import { realpathSync, readFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+
25
+ /** The one Harness release this build was validated against. */
26
+ export const EXPECTED_HARNESS_VERSION = '0.1.5-rc.2'
27
+
28
+ /** Host packages whose bundled copy the plugin binds to at runtime. */
29
+ export const HARNESS_GATE_PACKAGES = [
30
+ '@deepseek-ai/dsh-agent',
31
+ '@deepseek-ai/dsh-session',
32
+ ] as const
33
+
34
+ /** The installed-host CLI package that carries the Harness runtime. */
35
+ const HOST_PACKAGE_NAME = '@deepseek-ai/dsh'
36
+
37
+ /** Filesystem slice the probe needs; injectable so tests stay pure. */
38
+ export interface HarnessFs {
39
+ /** Resolve a symlinked entry to its real path. */
40
+ realpathSync(path: string): string
41
+ /** Read and parse one package.json; undefined when absent or unreadable. */
42
+ readManifest(path: string): { name?: unknown; version?: unknown } | undefined
43
+ }
44
+
45
+ const defaultHarnessFs: HarnessFs = {
46
+ realpathSync: path => realpathSync(path),
47
+ readManifest: path => {
48
+ try {
49
+ return JSON.parse(readFileSync(path, 'utf8')) as { name?: unknown; version?: unknown }
50
+ } catch {
51
+ return undefined
52
+ }
53
+ },
54
+ }
55
+
56
+ /** What the probe learned about the running host. */
57
+ export interface HarnessProbe {
58
+ /** Installed-host package root, when identified. */
59
+ readonly hostRoot?: string
60
+ /** Installed-host CLI version (`@deepseek-ai/dsh`). */
61
+ readonly hostVersion?: string
62
+ /** Version of each gate package bundled beside the host, when present. */
63
+ readonly packages: Readonly<Record<string, string | undefined>>
64
+ }
65
+
66
+ const UNKNOWN_PROBE: HarnessProbe = { packages: {} }
67
+
68
+ /**
69
+ * Walk up from one directory to the nearest `@deepseek-ai/dsh` package root.
70
+ * @param start - directory of the running CLI entry (already realpath'd).
71
+ * @param fs - filesystem slice to walk with.
72
+ * @returns the host package root, or undefined when no ancestor matches.
73
+ */
74
+ function findHostRoot(start: string, fs: HarnessFs): string | undefined {
75
+ let directory = start
76
+ for (;;) {
77
+ const manifest = fs.readManifest(join(directory, 'package.json'))
78
+ if (manifest !== undefined && manifest.name === HOST_PACKAGE_NAME) return directory
79
+ const parent = dirname(directory)
80
+ if (parent === directory) return undefined
81
+ directory = parent
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Identify the running host and the Harness versions it provides.
87
+ * @param argv1 - `process.argv[1]`, the running CLI entry path.
88
+ * @param fs - filesystem slice; defaults to the real one.
89
+ * @returns the probe result; `packages` is empty when the host is unknown.
90
+ */
91
+ export function probeRunningHarness(argv1: string | undefined, fs: HarnessFs = defaultHarnessFs): HarnessProbe {
92
+ if (argv1 === undefined || argv1 === '') return UNKNOWN_PROBE
93
+ let entry: string
94
+ try {
95
+ entry = fs.realpathSync(argv1)
96
+ } catch {
97
+ return UNKNOWN_PROBE
98
+ }
99
+ const hostRoot = findHostRoot(dirname(entry), fs)
100
+ if (hostRoot === undefined) return UNKNOWN_PROBE
101
+ const hostManifest = fs.readManifest(join(hostRoot, 'package.json'))
102
+ const hostVersion = typeof hostManifest?.version === 'string' ? hostManifest.version : undefined
103
+ const packages: Record<string, string | undefined> = {}
104
+ for (const name of HARNESS_GATE_PACKAGES) {
105
+ const manifest = fs.readManifest(join(hostRoot, 'node_modules', name, 'package.json'))
106
+ if (typeof manifest?.version === 'string') packages[name] = manifest.version
107
+ }
108
+ return { hostRoot, hostVersion, packages }
109
+ }
110
+
111
+ /** One host fact that disagrees with the version this build declares. */
112
+ export interface HarnessMismatch {
113
+ /** What was compared: the host CLI or one bundled package. */
114
+ readonly source: string
115
+ /** The version this build was validated against. */
116
+ readonly expected: string
117
+ /** The version the running host actually provides. */
118
+ readonly provided: string
119
+ }
120
+
121
+ /**
122
+ * Compare one probe against the expected snapshot. Unknown facts never
123
+ * mismatch: the gate must stay silent when it cannot judge, not brick a
124
+ * supported embedding on missing evidence.
125
+ * @param expected - the Harness version this build declares.
126
+ * @param probe - the running host's identified versions.
127
+ * @returns every concrete disagreement, empty when compatible or unknown.
128
+ */
129
+ export function harnessVersionMismatches(expected: string, probe: HarnessProbe): readonly HarnessMismatch[] {
130
+ const mismatches: HarnessMismatch[] = []
131
+ if (probe.hostVersion !== undefined && probe.hostVersion !== expected) {
132
+ mismatches.push({ source: `${HOST_PACKAGE_NAME} host`, expected, provided: probe.hostVersion })
133
+ }
134
+ for (const name of HARNESS_GATE_PACKAGES) {
135
+ const provided = probe.packages[name]
136
+ if (provided !== undefined && provided !== expected) {
137
+ mismatches.push({ source: name, expected, provided })
138
+ }
139
+ }
140
+ return mismatches
141
+ }
142
+
143
+ /**
144
+ * Render the gate failure the user sees on stderr.
145
+ * @param expected - the Harness version this build declares.
146
+ * @param mismatches - the disagreements to report.
147
+ * @param hostRoot - installed-host root, when identified, for the fix hint.
148
+ * @returns the multi-line diagnostic.
149
+ */
150
+ export function harnessGateMessage(expected: string, mismatches: readonly HarnessMismatch[], hostRoot?: string): string {
151
+ const lines = [`dsh: this dsh-code build requires DeepSeek Harness ${expected}, but this dsh provides:`]
152
+ for (const mismatch of mismatches) lines.push(`dsh: ${mismatch.source} ${mismatch.provided}`)
153
+ lines.push('dsh: update dsh-code to a release built for this dsh, or use a dsh matching ' + expected + '.')
154
+ if (hostRoot !== undefined) lines.push(`dsh: running host: ${hostRoot}`)
155
+ return lines.join('\n')
156
+ }
157
+
158
+ /**
159
+ * Refuse to run against an identified-but-incompatible host.
160
+ * @param expected - the Harness version this build declares.
161
+ * @param probe - the running host's identified versions.
162
+ * @throws an Error carrying the gate diagnostic when a version disagrees.
163
+ */
164
+ export function requireHarnessVersion(expected: string, probe: HarnessProbe): void {
165
+ const mismatches = harnessVersionMismatches(expected, probe)
166
+ if (mismatches.length === 0) return
167
+ throw new Error(harnessGateMessage(expected, mismatches, probe.hostRoot))
168
+ }
@@ -0,0 +1,77 @@
1
+ /** Global input recall: the appended prompts and their durable JSONL file.
2
+ *
3
+ * One JSONL file under the DSH home. A missing file means an empty history and
4
+ * unreadable or corrupt content degrades to the valid lines it could parse,
5
+ * silently — recall is a convenience surface, never a gate.
6
+ *
7
+ * @module @deepseek-ai/dsh-code/input-history
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs'
11
+ import { appendFile, mkdir } from 'node:fs/promises'
12
+ import { dirname } from 'node:path'
13
+ import {
14
+ historyLine,
15
+ HISTORY_MAX_ENTRIES,
16
+ needsCompaction,
17
+ parseHistoryFile,
18
+ serializeHistoryList,
19
+ } from '../session/history.ts'
20
+ import { writeFileAtomically } from '../settings-file.ts'
21
+
22
+ /** Live recall state plus its serialized durable writes. */
23
+ export interface InputHistoryStore {
24
+ /** The recall list, newest last; read live by the composer. */
25
+ readonly entries: () => readonly string[]
26
+ /** Append one submitted line and persist it; '' is ignored. */
27
+ readonly record: (text: string) => void
28
+ /** Await the pending writes (the quit flush). */
29
+ readonly flush: () => Promise<void>
30
+ }
31
+
32
+ /**
33
+ * Load the recall history and return its live store.
34
+ *
35
+ * Serialized history writes: each submission appends one JSON line at the end
36
+ * of the file, so concurrent terminals add entries after each other instead of
37
+ * overwriting snapshots they read at their own boot. A multi-line draft still
38
+ * occupies one physical line (JSON escapes the newline), and a regular-length
39
+ * line reaches the disk as one positioned write; an oversized paste may
40
+ * interleave mid-line, which the next parse simply drops.
41
+ *
42
+ * @param path - absolute path of the JSONL recall file.
43
+ * @param onFailure - receives the write failure message for a bounded notice.
44
+ */
45
+ export function createInputHistory(path: string, onFailure: (message: string) => void): InputHistoryStore {
46
+ let entries: readonly string[] = []
47
+ let chain: Promise<void> = Promise.resolve()
48
+ try {
49
+ const raw = readFileSync(path, 'utf8')
50
+ entries = parseHistoryFile(raw)
51
+ // Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
52
+ // accumulate in an append-only file; rewrite the canonical form once per
53
+ // boot. The rewrite rides the same chain, so it lands before any
54
+ // submission the user types next. An entry another terminal appends
55
+ // inside the read-to-rename window is dropped — a millisecond-scale gap
56
+ // at boot that recall tolerates by design.
57
+ if (needsCompaction(raw)) {
58
+ chain = chain.then(() => writeFileAtomically(path, serializeHistoryList(entries))).catch(() => {})
59
+ }
60
+ } catch {
61
+ entries = []
62
+ }
63
+ return {
64
+ entries: () => entries,
65
+ record: (text: string): void => {
66
+ if (text === '') return
67
+ entries = [...entries, text].slice(-HISTORY_MAX_ENTRIES)
68
+ chain = chain
69
+ .then(() => mkdir(dirname(path), { recursive: true }))
70
+ .then(() => appendFile(path, historyLine(text), 'utf8'))
71
+ .catch((error: unknown) => {
72
+ onFailure(error instanceof Error ? error.message : String(error))
73
+ })
74
+ },
75
+ flush: () => chain,
76
+ }
77
+ }
@@ -0,0 +1,49 @@
1
+ /** Shift+Tab mode-cycle stations for the terminal runner.
2
+ *
3
+ * Pure decision over the preset table and the committed plan fold: the runner
4
+ * applies the returned station, so the whole cycle is testable without a
5
+ * session.
6
+ *
7
+ * @module @deepseek-ai/dsh-code/mode-cycle
8
+ */
9
+
10
+ /** One Shift+Tab station decision for the mode cycle. */
11
+ export type ModeCycleDecision =
12
+ | { readonly kind: 'permission'; readonly preset: string }
13
+ | { readonly kind: 'plan-on' }
14
+ | { readonly kind: 'plan-off'; readonly preset: string }
15
+
16
+ /**
17
+ * Decide the next Shift+Tab station. The cycle keeps the preset table's
18
+ * own order (most restrictive first) and inserts ONE plan station between
19
+ * the most restrictive preset and the wrap target: with the shipped three
20
+ * presets the user sees workspace-write → danger-full-access → read-only
21
+ * → plan → workspace-write. Plan IS the most restrictive preset plus the
22
+ * plan prompt layer — entering it switches nothing (the cycle is already
23
+ * parked on read-only), and leaving it lands on the next preset after the
24
+ * most restrictive one. Without the /plan command the cycle is exactly the
25
+ * preset table.
26
+ *
27
+ * `planIntent` covers the committed fold's commit lag: upstream queues a
28
+ * plan switch during an open turn (and the command pipeline is async even
29
+ * idle), so the durable plan/mode event lands AFTER the press that chose
30
+ * it. While an intent from an earlier press is in flight it — not the
31
+ * stale committed fold — decides the station, so repeated presses advance
32
+ * the cycle instead of re-issuing the same plan transition (the stuck
33
+ * plan-on/plan-off toggle). Undefined falls back to the committed fold.
34
+ */
35
+ export function planCycleDecision(input: {
36
+ readonly names: readonly string[]
37
+ readonly current: string
38
+ readonly inPlan: boolean
39
+ readonly planAvailable: boolean
40
+ readonly planIntent?: boolean
41
+ }): ModeCycleDecision | undefined {
42
+ const names = input.names
43
+ if (names.length === 0) return undefined
44
+ const first = names[0]
45
+ if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
46
+ const at = names.indexOf(input.current)
47
+ if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
48
+ return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
49
+ }
@@ -0,0 +1,67 @@
1
+ /** User-level preference files under the DSH home.
2
+ *
3
+ * The statusline, theme, language, and animations customizations each live in
4
+ * one small JSON file. They share exactly two policies: a missing file is the
5
+ * default and stays silent while a corrupt one degrades to the default with a
6
+ * surfaced warning (a user-authored customization must never fail silently),
7
+ * and every save goes through the serialized crash-atomic writer.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/preferences
10
+ */
11
+
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { readSettingsObject, type UserSettingsPersistence } from '../settings-file.ts'
15
+
16
+ /** Absolute path of one preference file under the DSH home. */
17
+ export function preferencePath(fileName: string): string {
18
+ return join(homedir(), '.dsh', 'dsh-code', fileName)
19
+ }
20
+
21
+ /** Outcome of reading one preference file. */
22
+ export interface PreferenceRead<T> {
23
+ /** Parsed value; undefined when the file is missing or corrupt (keep the default). */
24
+ readonly value?: T
25
+ /** Corruption message to surface; undefined for a missing or clean file. */
26
+ readonly warning?: string
27
+ }
28
+
29
+ /**
30
+ * Read one field of a preference file. A missing file is silence — the default
31
+ * stands and nothing is reported — while unreadable JSON or a non-object
32
+ * document both warn and keep the default.
33
+ * @param path - absolute path of the preference file.
34
+ * @param field - the JSON field the file carries.
35
+ * @param parse - narrows the raw field to the usable value.
36
+ * @returns the parsed value and/or the warning to surface.
37
+ */
38
+ export function readPreference<T>(path: string, field: string, parse: (raw: unknown) => T): PreferenceRead<T> {
39
+ try {
40
+ return { value: parse(readSettingsObject(path)[field]) }
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
43
+ return { warning: error instanceof Error ? error.message : String(error) }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Persist one preference field through the serialized crash-atomic writer.
49
+ * A failed write is reported through `onFailure` instead of rejecting.
50
+ * @param persistence - the shared user-settings writer.
51
+ * @param path - absolute path of the preference file.
52
+ * @param field - the JSON field the file carries.
53
+ * @param value - the value to store under `field`.
54
+ * @param onFailure - receives the failure message for a bounded notice.
55
+ */
56
+ export function savePreference(
57
+ persistence: UserSettingsPersistence,
58
+ path: string,
59
+ field: string,
60
+ value: unknown,
61
+ onFailure: (message: string) => void,
62
+ ): void {
63
+ void persistence.save(path, JSON.stringify({ [field]: value }, null, 2) + '\n')
64
+ .catch((error: unknown) => {
65
+ onFailure(error instanceof Error ? error.message : String(error))
66
+ })
67
+ }
@@ -0,0 +1,53 @@
1
+ /** Ordered terminal quit cleanup.
2
+ *
3
+ * Pure sequencing: every step rejection is contained and the exit request is
4
+ * always reached exactly once, so a failing flush never strands the process.
5
+ *
6
+ * @module @deepseek-ai/dsh-code/quit
7
+ */
8
+
9
+ /** One ordered step of the terminal quit cleanup. */
10
+ export interface QuitCleanupStep {
11
+ /** Step label used in diagnostics and tests. */
12
+ readonly name: string
13
+ /** The step's async work; a rejection is contained by the sequence. */
14
+ readonly run: () => Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
19
+ * contained (reported through `onError`) so a failed flush or dispose never
20
+ * skips the remaining cleanup; the exit request is always reached exactly
21
+ * once.
22
+ * @param steps - the cleanup steps in dependency order (settle the visible
23
+ * session, await the final in-flight composition, await durable recall).
24
+ * @param exit - the terminal exit request (code 0).
25
+ * @param onError - optional failure sink; called once per failing step and
26
+ * itself contained, so a throwing sink cannot abort the sequence.
27
+ * @returns the names of the steps that started, in order (for tests).
28
+ */
29
+ export async function runQuitSequence(
30
+ steps: readonly QuitCleanupStep[],
31
+ exit: (code: number) => void,
32
+ onError?: (name: string, error: unknown) => void,
33
+ ): Promise<readonly string[]> {
34
+ const started: string[] = []
35
+ for (const step of steps) {
36
+ started.push(step.name)
37
+ try {
38
+ await step.run()
39
+ } catch (error) {
40
+ try {
41
+ onError?.(step.name, error)
42
+ } catch {
43
+ // The failure sink must never abort the cleanup sequence.
44
+ }
45
+ }
46
+ }
47
+ try {
48
+ exit(0)
49
+ } catch {
50
+ // The exit request itself must not become an unhandled rejection.
51
+ }
52
+ return started
53
+ }
@@ -0,0 +1,55 @@
1
+ /** Cross-session search rows for the /search panel.
2
+ *
3
+ * Owns the panel row shape and the pure mapping from a session-query hit, so
4
+ * the row contract does not live inside the panel component that renders it
5
+ * and the runner can build rows without importing the panel module.
6
+ *
7
+ * @module @deepseek-ai/dsh-code/search-rows
8
+ */
9
+
10
+ import type { SessionHeader } from '@deepseek-ai/dsh-session'
11
+
12
+ /** One cross-session full-text search hit mapped from the session-query engine. */
13
+ export interface SearchRow {
14
+ /** Session id (Enter resumes it through the switch machinery). */
15
+ readonly id: string
16
+ /** Display label: session title or the short id form. */
17
+ readonly label: string
18
+ /** Secondary facts line (workspace · preset markers). */
19
+ readonly detail: string
20
+ /** Bounded plain-text excerpt around the strongest match. */
21
+ readonly snippet: string
22
+ /** Match timestamp (relative labels derive from it). */
23
+ readonly updatedAt: number
24
+ /** Whether the hit is a delegated subagent conversation (not resumable). */
25
+ readonly subagent: boolean
26
+ /** Whether Enter may switch into it. */
27
+ readonly resumable: boolean
28
+ }
29
+
30
+ /**
31
+ * Map one cross-session full-text hit onto the /search panel's row (pure).
32
+ * Labels fall back to the short id form — the engine's hit carries the
33
+ * strongest matching event, not the title observation.
34
+ */
35
+ export function searchHitToRow(hit: {
36
+ header: SessionHeader
37
+ bestMatch: { snippet: string; time: number }
38
+ }): SearchRow {
39
+ const subagent = hit.header.origin === 'subagent'
40
+ const cwd = hit.header.cwd ?? ''
41
+ // Session cwds may arrive in either separator style regardless of the
42
+ // observing host (a workspace synced from Windows), so split on both.
43
+ const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
44
+ const preset = hit.header.agentPreset ?? ''
45
+ const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
46
+ return {
47
+ id: hit.header.id,
48
+ label: hit.header.id.slice(-12),
49
+ detail: [workspace, preset].filter(part => part !== '').join(' · '),
50
+ snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
51
+ updatedAt: hit.bestMatch.time,
52
+ subagent,
53
+ resumable: !subagent,
54
+ }
55
+ }
@@ -0,0 +1,206 @@
1
+ /** Session directory IO exposed to the picker, /delete, and /export.
2
+ *
3
+ * The kernel persistence seam has NO deletion API by design — logs accumulate
4
+ * "until removed externally" — so removal is planned, layout-checked, and
5
+ * lease-guarded here before any file is touched. Every read goes through the
6
+ * in-process session-query engine, so this module stays free of `app.ts` and
7
+ * of the Ink tree.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/runner/session-io
10
+ */
11
+
12
+ import { readdir, rm, stat } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
15
+ import { buildExportMarkdown } from '../render/export.ts'
16
+ import {
17
+ acquireSessionDeletionLeases,
18
+ isSessionArtifactName,
19
+ jsonlSessionRoot,
20
+ mergeSessionTitles,
21
+ planSessionDeletion,
22
+ projectSessionRows,
23
+ releaseSessionDeletionLeases,
24
+ sessionArtifactDirectory,
25
+ sessionDirectoryFor,
26
+ sessionRowMatchesQuery,
27
+ type SessionDeletionPersistence,
28
+ type SessionDirectoryOptions,
29
+ type SessionQueryService,
30
+ type SessionRow,
31
+ } from '../session/session-directory.ts'
32
+ import { createTranscriptStore } from '../session/store.ts'
33
+
34
+ /**
35
+ * The persistence surface this module needs: the JSONL backend's public
36
+ * session root plus its per-session write handles. Typing the narrow shape
37
+ * instead of the upstream service keeps the IO injectable from a test double.
38
+ */
39
+ export interface SessionIoPersistence extends SessionDeletionPersistence {
40
+ /** JSONL backend plugin config carrying the session root, when exposed. */
41
+ readonly config?: { readonly root?: unknown }
42
+ }
43
+
44
+ /** Services the session IO closes over. */
45
+ export interface SessionIoServices {
46
+ /** In-process session-query engine; absent in profiles without one. */
47
+ readonly sessionQuery?: SessionQueryService
48
+ /** The durable persistence service; absent in profiles without one. */
49
+ readonly persistence?: SessionIoPersistence
50
+ /** The session currently visible in the UI (self-deletion guard). */
51
+ readonly activeSessionId: () => string | undefined
52
+ }
53
+
54
+ /** Session directory reads and the guarded /delete operation. */
55
+ export interface SessionIo {
56
+ /** Project the session directory for the picker (query-filtered). */
57
+ readonly loadSessions: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
58
+ /** Delete one session subtree, returning the outcome line. */
59
+ readonly deleteSession: (id: string) => Promise<string>
60
+ /** Render one session's whole transcript as export Markdown. */
61
+ readonly loadSessionTranscript: (id: string, signal?: AbortSignal) => Promise<string>
62
+ }
63
+
64
+ /**
65
+ * Bind the session IO to one runner's services.
66
+ * @param services - the query engine, persistence, and the live-session probe.
67
+ * @returns the picker/delete/export reads used by the app bridge.
68
+ */
69
+ export function createSessionIo(services: SessionIoServices): SessionIo {
70
+ const { sessionQuery, persistence } = services
71
+
72
+ const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
73
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
74
+ const records = await sessionQuery.listSessions(signal)
75
+ // Last-activity timestamps for sorting (codex UpdatedAt default): the
76
+ // newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
77
+ // the persistence `locate()` query, so paths are derived from the
78
+ // backend's public config root. Backends without a JSONL config (or
79
+ // vanished directories) fall back to createdAt inside the projection.
80
+ const root = jsonlSessionRoot(persistence)
81
+ const updated = new Map<string, number>()
82
+ if (root !== undefined) {
83
+ await Promise.all(records.map(async record => {
84
+ try {
85
+ const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
86
+ const entries = await readdir(dir, { withFileTypes: true })
87
+ const stats = await Promise.all(
88
+ entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
89
+ .map(entry => stat(join(dir, entry.name))),
90
+ )
91
+ const newest = Math.max(...stats.map(info => info.mtimeMs))
92
+ if (Number.isFinite(newest)) updated.set(record.header.id, newest)
93
+ } catch {
94
+ // Artifact gone or unreadable: the projection falls back to createdAt.
95
+ }
96
+ }))
97
+ }
98
+ const projected = projectSessionRows(records, { ...options, query: '' }, updated)
99
+ // Titles are the expensive fold. Fetch the first picker page when idle;
100
+ // a non-empty query loads more so the displayed title can match.
101
+ const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
102
+ const page = projected.slice(0, titleBudget)
103
+ if (page.length === 0) return projected
104
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
105
+ const titled = mergeSessionTitles(projected, observations)
106
+ return titled.filter(row => sessionRowMatchesQuery(row, options.query))
107
+ }
108
+
109
+ /**
110
+ * Delete one session subtree (/delete, codex semantics: subagent threads go
111
+ * with their root). The kernel persistence seam has NO deletion API by
112
+ * design — logs accumulate "until removed externally" — so this is the
113
+ * controlled external removal, in three phases with a hard boundary
114
+ * between planning and touching the filesystem:
115
+ *
116
+ * 1. `planSessionDeletion` collects the subtree and refuses when the root
117
+ * or ANY member is live (a live child would outlive its deleted
118
+ * parent), ordering the plan children-first.
119
+ * 2. Every plan node must derive to a guarded artifact directory
120
+ * (`encodeSegment(id)` layout beneath the backend's config root).
121
+ * Backends without a derivable artifact (non-JSONL) refuse the WHOLE
122
+ * deletion here — no file has been touched yet, so a backend or layout
123
+ * surprise can never strand a half-deleted subtree.
124
+ * 3. Acquire every node's public persistence write handle before touching
125
+ * files. The JSONL backend holds its cross-process kernel lease for each
126
+ * handle, so another terminal's live session refuses the whole deletion.
127
+ * 4. Artifacts are removed children-first while every lease remains held:
128
+ * only an I/O error mid-delete can stop it short (reported with
129
+ * removed/total counts), leaving the shallowest lineage intact.
130
+ *
131
+ * @param id - the root session id to delete.
132
+ * @returns the outcome line for the panel/notice.
133
+ */
134
+ const deleteSession = async (id: string): Promise<string> => {
135
+ if (sessionQuery === undefined) return 'session query is unavailable in this profile'
136
+ const activeId = services.activeSessionId()
137
+ if (activeId !== undefined && activeId === id) return 'cannot delete the session you are using — switch or /new first'
138
+ const records = await sessionQuery.listSessions()
139
+ const plan = planSessionDeletion(records, id)
140
+ if (!plan.ok) return plan.reason
141
+ // Phase 2 completes the plan before the first rm: derive and
142
+ // layout-check every node up front, so a refusal never leaves a
143
+ // partially removed subtree behind.
144
+ const root = jsonlSessionRoot(persistence)
145
+ if (root === undefined || persistence === undefined) {
146
+ return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
147
+ }
148
+ const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
149
+ const dirs = new Map<string, string>()
150
+ for (const node of plan.nodes) {
151
+ const record = byId.get(node.id)
152
+ if (record === undefined) return `no persisted session matches "${node.id}"`
153
+ const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
154
+ if (dir === undefined) {
155
+ return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
156
+ }
157
+ dirs.set(node.id, dir)
158
+ }
159
+ let leases
160
+ try {
161
+ leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
162
+ } catch (error: unknown) {
163
+ if (error instanceof SessionAlreadyOwnedError) {
164
+ return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
165
+ }
166
+ return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
167
+ }
168
+
169
+ let removed = 0
170
+ let outcome: string | undefined
171
+ for (const node of plan.nodes) {
172
+ const dir = dirs.get(node.id)!
173
+ try {
174
+ // Remove every canonical generation artifact this build knows; other
175
+ // sibling files are never ours to delete. The POSIX session.lock file
176
+ // deliberately remains because unlinking a held flock inode would
177
+ // forfeit the backend's exclusion guarantee.
178
+ const entries = await readdir(dir, { withFileTypes: true })
179
+ for (const entry of entries) {
180
+ if (entry.isFile() && isSessionArtifactName(entry.name)) {
181
+ await rm(join(dir, entry.name), { force: true })
182
+ }
183
+ }
184
+ await rm(dir, { force: true, recursive: false }).catch(() => {})
185
+ removed += 1
186
+ } catch (error: unknown) {
187
+ outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
188
+ break
189
+ }
190
+ }
191
+ outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
192
+ try {
193
+ await releaseSessionDeletionLeases(leases)
194
+ } catch (error: unknown) {
195
+ return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
196
+ }
197
+ return outcome
198
+ }
199
+
200
+ const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
201
+ if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
202
+ const snapshot = await sessionQuery.readSession(id, signal)
203
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
204
+ }
205
+ return { loadSessions, deleteSession, loadSessionTranscript }
206
+ }