dsh-code 1.0.4 → 1.0.5
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 +9 -7
- package/bin/deepseek.mjs +221 -11
- package/cordis.patch.yml +12 -14
- package/lib/index.mjs +164 -94
- package/lib/types/git-workflow.d.ts +7 -2
- package/lib/types/history.d.ts +18 -11
- package/lib/types/presets.d.ts +4 -1
- package/lib/types/questions.d.ts +16 -12
- package/lib/types/render/projection.d.ts +2 -1
- package/lib/types/settings-file.d.ts +8 -0
- package/package.json +49 -46
- package/src/app.ts +1 -1
- package/src/git-workflow.ts +29 -10
- package/src/history.ts +22 -13
- package/src/index.ts +50 -23
- package/src/permissions.ts +1 -1
- package/src/presets.ts +19 -6
- package/src/questions.ts +57 -74
- package/src/render/export.ts +7 -7
- package/src/render/projection.ts +2 -1
- package/src/settings-file.ts +38 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-code",
|
|
3
3
|
"description": "DeepSeek Harness CLI core bundle: interactive coding terminal, durable sessions, and model management for dsh --profile cli",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"deepseek": "./bin/deepseek.mjs",
|
|
@@ -63,8 +63,9 @@
|
|
|
63
63
|
"node": "^22.19 || >=24"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@deepseek-ai/dsh-authorization": "0.1.
|
|
67
|
-
"@deepseek-ai/dsh-web-fetch-http": "0.1.
|
|
66
|
+
"@deepseek-ai/dsh-authorization": "0.1.2-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-web-fetch-http": "0.1.2-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
|
|
68
69
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
69
70
|
"chalk": "^5.6.2",
|
|
70
71
|
"commander": "^15.0.0",
|
|
@@ -74,32 +75,32 @@
|
|
|
74
75
|
"peerDependencies": {
|
|
75
76
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
76
77
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2",
|
|
77
|
-
"@deepseek-ai/dsh-attachment": "0.1.
|
|
78
|
-
"@deepseek-ai/dsh-agent": "0.1.
|
|
79
|
-
"@deepseek-ai/dsh-agent-default-model": "0.1.
|
|
80
|
-
"@deepseek-ai/dsh-agent-presets": "0.1.
|
|
81
|
-
"@deepseek-ai/dsh-cmdline": "0.1.
|
|
82
|
-
"@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.
|
|
83
|
-
"@deepseek-ai/dsh-commands": "0.1.
|
|
84
|
-
"@deepseek-ai/dsh-compaction": "0.1.
|
|
85
|
-
"@deepseek-ai/dsh-cordis-host-runner": "0.1.
|
|
86
|
-
"@deepseek-ai/dsh-credentials": "0.1.
|
|
87
|
-
"@deepseek-ai/dsh-file-reference-local": "0.1.
|
|
88
|
-
"@deepseek-ai/dsh-goal": "0.1.
|
|
89
|
-
"@deepseek-ai/dsh-invariants": "0.1.
|
|
90
|
-
"@deepseek-ai/dsh-jobs": "0.1.
|
|
91
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
92
|
-
"@deepseek-ai/dsh-llm-retry": "0.1.
|
|
93
|
-
"@deepseek-ai/dsh-permission-presets": "0.1.
|
|
94
|
-
"@deepseek-ai/dsh-plan-mode": "0.1.
|
|
95
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.1.
|
|
96
|
-
"@deepseek-ai/dsh-session": "0.1.
|
|
97
|
-
"@deepseek-ai/dsh-session-persistence": "0.1.
|
|
98
|
-
"@deepseek-ai/dsh-session-reference": "0.1.
|
|
99
|
-
"@deepseek-ai/dsh-session-title": "0.1.
|
|
100
|
-
"@deepseek-ai/dsh-skill": "0.1.
|
|
101
|
-
"@deepseek-ai/dsh-user-approval": "0.1.
|
|
102
|
-
"@deepseek-ai/dsh-user-questions": "0.1.
|
|
78
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-agent-default-model": "0.1.2-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-cmdline": "0.1.2-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.2-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-commands": "0.1.2-rc.1",
|
|
85
|
+
"@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
|
|
86
|
+
"@deepseek-ai/dsh-cordis-host-runner": "0.1.2-rc.1",
|
|
87
|
+
"@deepseek-ai/dsh-credentials": "0.1.2-rc.1",
|
|
88
|
+
"@deepseek-ai/dsh-file-reference-local": "0.1.2-rc.1",
|
|
89
|
+
"@deepseek-ai/dsh-goal": "0.1.2-rc.1",
|
|
90
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
|
|
91
|
+
"@deepseek-ai/dsh-jobs": "0.1.2-rc.1",
|
|
92
|
+
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
93
|
+
"@deepseek-ai/dsh-llm-retry": "0.1.2-rc.1",
|
|
94
|
+
"@deepseek-ai/dsh-permission-presets": "0.1.2-rc.1",
|
|
95
|
+
"@deepseek-ai/dsh-plan-mode": "0.1.2-rc.1",
|
|
96
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
|
97
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
98
|
+
"@deepseek-ai/dsh-session-persistence": "0.1.2-rc.1",
|
|
99
|
+
"@deepseek-ai/dsh-session-reference": "0.1.2-rc.1",
|
|
100
|
+
"@deepseek-ai/dsh-session-title": "0.1.2-rc.1",
|
|
101
|
+
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
|
102
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
|
|
103
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-rc.1"
|
|
103
104
|
},
|
|
104
105
|
"peerDependenciesMeta": {
|
|
105
106
|
"@deepseek-ai/cordis": { "optional": true },
|
|
@@ -132,23 +133,25 @@
|
|
|
132
133
|
"@deepseek-ai/dsh-user-questions": { "optional": true }
|
|
133
134
|
},
|
|
134
135
|
"devDependencies": {
|
|
135
|
-
"@deepseek-ai/dsh-agent-presets": "0.1.
|
|
136
|
-
"@deepseek-ai/dsh-attachment": "0.1.
|
|
137
|
-
"@deepseek-ai/dsh-compaction": "0.1.
|
|
138
|
-
"@deepseek-ai/dsh-commands": "0.1.
|
|
139
|
-
"@deepseek-ai/dsh-credentials": "0.1.
|
|
140
|
-
"@deepseek-ai/dsh-
|
|
141
|
-
"@deepseek-ai/dsh-
|
|
142
|
-
"@deepseek-ai/dsh-
|
|
143
|
-
"@deepseek-ai/dsh-
|
|
144
|
-
"@deepseek-ai/dsh-
|
|
145
|
-
"@deepseek-ai/dsh-
|
|
146
|
-
"@deepseek-ai/dsh-
|
|
147
|
-
"@deepseek-ai/dsh-
|
|
148
|
-
"@deepseek-ai/dsh-session-
|
|
149
|
-
"@deepseek-ai/dsh-
|
|
150
|
-
"@deepseek-ai/dsh-
|
|
151
|
-
"@deepseek-ai/dsh-
|
|
136
|
+
"@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
|
|
137
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
|
|
138
|
+
"@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
|
|
139
|
+
"@deepseek-ai/dsh-commands": "0.1.2-rc.1",
|
|
140
|
+
"@deepseek-ai/dsh-credentials": "0.1.2-rc.1",
|
|
141
|
+
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
|
142
|
+
"@deepseek-ai/dsh-tool-todo": "0.1.2-rc.1",
|
|
143
|
+
"@deepseek-ai/dsh-goal": "0.1.2-rc.1",
|
|
144
|
+
"@deepseek-ai/dsh-jobs": "0.1.2-rc.1",
|
|
145
|
+
"@deepseek-ai/dsh-llm-retry": "0.1.2-rc.1",
|
|
146
|
+
"@deepseek-ai/dsh-permission-presets": "0.1.2-rc.1",
|
|
147
|
+
"@deepseek-ai/dsh-plan-mode": "0.1.2-rc.1",
|
|
148
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
|
149
|
+
"@deepseek-ai/dsh-session-persistence": "0.1.2-rc.1",
|
|
150
|
+
"@deepseek-ai/dsh-session-reference": "0.1.2-rc.1",
|
|
151
|
+
"@deepseek-ai/dsh-session-title": "0.1.2-rc.1",
|
|
152
|
+
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
|
153
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
|
|
154
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-rc.1",
|
|
152
155
|
"@types/node": "^24.0.0",
|
|
153
156
|
"@types/react": "~18.3.1",
|
|
154
157
|
"tsdown": "^0.22.2",
|
package/src/app.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { Box, Static, Text, useInput, useStdin, useStdout, type Key } from 'ink'
|
|
22
22
|
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
23
23
|
import type { ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
24
|
-
import type { TodoItem } from '@deepseek-ai/dsh-
|
|
24
|
+
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
|
|
25
25
|
import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions'
|
|
26
26
|
import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
|
|
27
27
|
import {
|
package/src/git-workflow.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Git inspection used by /diff and /review. Every diff
|
|
3
|
+
* invocation carries --no-ext-diff and --no-textconv, so configured
|
|
4
|
+
* external diff drivers and text converters can never execute as a
|
|
5
|
+
* side effect of reading a diff.
|
|
6
|
+
*/
|
|
2
7
|
|
|
3
8
|
import { execFile } from 'node:child_process'
|
|
4
9
|
|
|
@@ -36,12 +41,12 @@ export function parseGitDiffFiles(text: string): readonly GitDiffFile[] {
|
|
|
36
41
|
/** Parse the intentionally small, option-safe /diff argument vocabulary. */
|
|
37
42
|
export function parseGitDiffSpec(argument: string): GitDiffSpec {
|
|
38
43
|
const value = argument.trim()
|
|
39
|
-
if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--unified=3', 'HEAD', '--'] }
|
|
44
|
+
if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', 'HEAD', '--'] }
|
|
40
45
|
if (value === '--staged' || value === '--cached') {
|
|
41
|
-
return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--unified=3', '--cached', '--'] }
|
|
46
|
+
return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--cached', '--'] }
|
|
42
47
|
}
|
|
43
48
|
if (value.startsWith('-') || /\s/u.test(value)) throw new Error('usage: /diff [--staged|git-ref]')
|
|
44
|
-
return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--unified=3', value, '--'] }
|
|
49
|
+
return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', value, '--'] }
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
|
|
@@ -56,8 +61,18 @@ function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal):
|
|
|
56
61
|
})
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
/** Arguments for the unstaged-only fallback below. */
|
|
65
|
+
const UNSTAGED_DIFF_ARGS = ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--'] as const
|
|
66
|
+
|
|
67
|
+
/** Whether the repository has at least one commit (a HEAD revision). */
|
|
68
|
+
function hasHeadRevision(cwd: string, signal?: AbortSignal): Promise<boolean> {
|
|
69
|
+
return executeGit(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal)
|
|
70
|
+
.then(() => true)
|
|
71
|
+
.catch(() => false)
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
/**
|
|
60
|
-
* Load one complete textual diff without invoking external
|
|
75
|
+
* Load one complete textual diff without invoking external programs.
|
|
61
76
|
* @param signal - aborted by the caller on session switches/quit, killing the
|
|
62
77
|
* git subprocess instead of letting a stale repository's diff land later.
|
|
63
78
|
*/
|
|
@@ -67,11 +82,15 @@ export async function loadGitDiff(cwd: string, argument: string, signal?: AbortS
|
|
|
67
82
|
const text = await executeGit(cwd, spec.args, signal)
|
|
68
83
|
return { title: `git diff - ${spec.label}`, files: parseGitDiffFiles(text) }
|
|
69
84
|
} catch (error: unknown) {
|
|
70
|
-
//
|
|
71
|
-
// the default form
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
// Only a repository without commits (no HEAD to diff against) may
|
|
86
|
+
// narrow the default form to the unstaged fallback. Every other
|
|
87
|
+
// failure — output past the buffer limit, a corrupt index, a missing
|
|
88
|
+
// repository, or the caller aborting between the two calls — must
|
|
89
|
+
// surface, not silently shrink what /diff and /review end up seeing
|
|
90
|
+
// (an aborted probe would otherwise masquerade as an unborn repo).
|
|
91
|
+
if (argument.trim() !== '' || signal?.aborted === true || (await hasHeadRevision(cwd, signal))) throw error
|
|
92
|
+
const text = await executeGit(cwd, UNSTAGED_DIFF_ARGS, signal)
|
|
93
|
+
return { title: 'git diff - working tree (no commits yet)', files: parseGitDiffFiles(text) }
|
|
75
94
|
}
|
|
76
95
|
}
|
|
77
96
|
|
package/src/history.ts
CHANGED
|
@@ -42,16 +42,25 @@ export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readon
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
45
|
+
* The append unit for the persistent file: one JSON line, so a multi-line
|
|
46
|
+
* draft still occupies exactly one physical line. Each submission appends
|
|
47
|
+
* this unit at the end of the file, so concurrent terminals add entries
|
|
48
|
+
* after each other. Node chunks one append at 512 KiB: a pasted entry
|
|
49
|
+
* beyond that size could interleave mid-line with another writer's
|
|
50
|
+
* chunks, and the damaged line then drops out at the next parse —
|
|
51
|
+
* recall tolerates the loss by design.
|
|
51
52
|
*/
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
export function historyLine(text: string): string {
|
|
54
|
+
return serializeHistoryEntry(text) + '\n'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the file on disk differs from its canonical form (deduped and
|
|
59
|
+
* capped). True means stale lines have accumulated and the next boot
|
|
60
|
+
* should rewrite it once, atomically.
|
|
61
|
+
*/
|
|
62
|
+
export function needsCompaction(raw: string, max = HISTORY_MAX_ENTRIES): boolean {
|
|
63
|
+
return serializeHistoryList(parseHistoryFile(raw, max)) !== raw
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
/**
|
|
@@ -70,10 +79,10 @@ export function recordLocalEntry(local: readonly string[], text: string, max = H
|
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
73
|
-
* Serialize a capped entry list to the history file format (one JSON line
|
|
74
|
-
* entry, trailing newline). The
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* Serialize a capped entry list to the history file format (one JSON line
|
|
83
|
+
* per entry, trailing newline). The boot-time compaction writes this
|
|
84
|
+
* canonical form once when stale lines have accumulated; submissions
|
|
85
|
+
* themselves only ever append a single line.
|
|
77
86
|
* @param entries - the entries to persist, oldest first.
|
|
78
87
|
* @returns the file content, '' for an empty list.
|
|
79
88
|
*/
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
|
-
import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
15
|
+
import { appendFile as appendFileAsync, mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
16
|
import { basename, dirname, join } from 'node:path'
|
|
17
17
|
import { createElement } from 'react'
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -48,10 +48,13 @@ import {
|
|
|
48
48
|
} from './provider-settings.ts'
|
|
49
49
|
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
50
50
|
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
51
|
+
// Type-only import merges the settings Events declarations ('settings/updated',
|
|
52
|
+
// 'settings/document-updated') into this program's Cordis bus typing.
|
|
53
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
51
54
|
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
52
55
|
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
53
56
|
import { parseStatuslineItems } from './render/status.ts'
|
|
54
|
-
import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
57
|
+
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
55
58
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
56
59
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
57
60
|
import { buildExportMarkdown } from './render/export.ts'
|
|
@@ -70,7 +73,7 @@ import { selectForkSeed } from './fork.ts'
|
|
|
70
73
|
import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
|
|
71
74
|
import type { TuiStartup } from './startup.ts'
|
|
72
75
|
import { SessionSwitchQueue } from './session-switch.ts'
|
|
73
|
-
import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
|
|
76
|
+
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
74
77
|
import {
|
|
75
78
|
applyPendingPermission,
|
|
76
79
|
cyclePermission as cyclePermissionPreset,
|
|
@@ -94,7 +97,7 @@ import {
|
|
|
94
97
|
type SessionQueryService,
|
|
95
98
|
type SessionRow,
|
|
96
99
|
} from './session-directory.ts'
|
|
97
|
-
import { createUserSettingsPersistence } from './settings-file.ts'
|
|
100
|
+
import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
|
|
98
101
|
|
|
99
102
|
/** Stable Cordis plugin name. */
|
|
100
103
|
export const name = 'tui-runner'
|
|
@@ -406,7 +409,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
406
409
|
? {}
|
|
407
410
|
: { picked: pendingSelection }
|
|
408
411
|
let mode = next.resume ? next.mode : next.mode ?? pendingMode
|
|
409
|
-
|
|
412
|
+
// An explicit `--mode` or the settings-layer service default may still name
|
|
413
|
+
// an id an upstream rename retired (code → ptc); normalize both.
|
|
414
|
+
if (!next.resume) mode = (await presets.resolve(normalizePresetId(mode ?? presets.defaultId))).id
|
|
410
415
|
const setup = async (agentCtx: Context): Promise<void> => {
|
|
411
416
|
const sessionPreset = next.resume
|
|
412
417
|
? resolvePreset(agentCtx.agent!.session)
|
|
@@ -457,7 +462,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
457
462
|
handle,
|
|
458
463
|
agent: handle.agent,
|
|
459
464
|
session,
|
|
460
|
-
store: createTranscriptStore(session.
|
|
465
|
+
store: createTranscriptStore(session.snapshotEvents()),
|
|
461
466
|
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
462
467
|
mode: mode ?? 'standard',
|
|
463
468
|
selection: selectionState,
|
|
@@ -590,10 +595,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
590
595
|
return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
|
|
591
596
|
})
|
|
592
597
|
|
|
593
|
-
// ask_user_question
|
|
594
|
-
//
|
|
595
|
-
// through
|
|
596
|
-
const questions: QuestionStore = mountQuestionProvider(
|
|
598
|
+
// ask_user_question answerer: one waterfall listener, one request on
|
|
599
|
+
// screen at a time. Plan reviews (exit_plan_mode) arrive through this same
|
|
600
|
+
// pipe; sibling answerers stay usable through the claim/defer split.
|
|
601
|
+
const questions: QuestionStore = mountQuestionProvider(
|
|
602
|
+
ctx,
|
|
603
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
604
|
+
)
|
|
597
605
|
|
|
598
606
|
// The bridge the React app registers on mount: local notices from the
|
|
599
607
|
// process side (unknown commands, switch confirmations, cancels).
|
|
@@ -696,22 +704,39 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
696
704
|
// recall is a convenience surface, never a gate.
|
|
697
705
|
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
698
706
|
let inputHistory: readonly string[] = []
|
|
707
|
+
let historyWriteChain: Promise<void> = Promise.resolve()
|
|
699
708
|
try {
|
|
700
|
-
|
|
709
|
+
const rawHistory = readFileSync(historyPath, 'utf8')
|
|
710
|
+
inputHistory = parseHistoryFile(rawHistory)
|
|
711
|
+
// Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
|
|
712
|
+
// accumulate in an append-only file; rewrite the canonical form once
|
|
713
|
+
// per boot. The rewrite rides the same chain, so it lands before any
|
|
714
|
+
// submission the user types next. An entry another terminal appends
|
|
715
|
+
// inside the read-to-rename window is dropped — a millisecond-scale
|
|
716
|
+
// gap at boot that recall tolerates by design.
|
|
717
|
+
if (needsCompaction(rawHistory)) {
|
|
718
|
+
historyWriteChain = historyWriteChain
|
|
719
|
+
.then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
|
|
720
|
+
.catch(() => {})
|
|
721
|
+
}
|
|
701
722
|
} catch {
|
|
702
723
|
inputHistory = []
|
|
703
724
|
}
|
|
704
|
-
/**
|
|
705
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Serialized history writes: each submission appends one JSON line at the
|
|
727
|
+
* end of the file, so concurrent terminals add entries after each other
|
|
728
|
+
* instead of overwriting snapshots they read at their own boot. A
|
|
729
|
+
* multi-line draft still occupies one physical line (JSON escapes the
|
|
730
|
+
* newline), and a regular-length line reaches the disk as one positioned
|
|
731
|
+
* write; an oversized paste may interleave mid-line, which the next
|
|
732
|
+
* parse simply drops.
|
|
733
|
+
*/
|
|
706
734
|
const recordHistory = (text: string): void => {
|
|
707
735
|
if (text === '') return
|
|
708
736
|
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
709
|
-
// Write the whole current list, serialized per submission: the file is
|
|
710
|
-
// never read back on the submit path, so rapid same-process submissions
|
|
711
|
-
// cannot lose entries to a read-modify-write race.
|
|
712
737
|
historyWriteChain = historyWriteChain
|
|
713
738
|
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
714
|
-
.then(() =>
|
|
739
|
+
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
715
740
|
.catch((writeError: unknown) => {
|
|
716
741
|
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
717
742
|
})
|
|
@@ -1483,11 +1508,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1483
1508
|
}
|
|
1484
1509
|
|
|
1485
1510
|
const reviewChanges = (argument: string): void => {
|
|
1511
|
+
// Works from a bare launch too: with no session yet the read-only
|
|
1512
|
+
// choice goes to pendingPermission (materialized when the first
|
|
1513
|
+
// session composes) and the review prompt queues behind that
|
|
1514
|
+
// creation exactly like a typed first submission. The identity guard
|
|
1515
|
+
// below still aborts a load that outlives a mid-flight switch —
|
|
1516
|
+
// including one landing on an undefined agent.
|
|
1486
1517
|
const currentAgent = agent
|
|
1487
|
-
if (currentAgent === undefined) {
|
|
1488
|
-
bridge.notify('no session yet - submit a message to start', 'warning')
|
|
1489
|
-
return
|
|
1490
|
-
}
|
|
1491
1518
|
// The diff loads from the CALLING session's cwd; capture that
|
|
1492
1519
|
// workspace and this turn's identity so a switch mid-load can neither
|
|
1493
1520
|
// flip the new session read-only nor send the old workspace's review
|
|
@@ -1529,7 +1556,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1529
1556
|
if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
|
|
1530
1557
|
throw new Error('usage: /fork [event-seq]')
|
|
1531
1558
|
}
|
|
1532
|
-
const seed = selectForkSeed(session.
|
|
1559
|
+
const seed = selectForkSeed(session.snapshotEvents(), atSeq)
|
|
1533
1560
|
const id = `session-${randomUUID()}`
|
|
1534
1561
|
requestSwitch({
|
|
1535
1562
|
target: {
|
|
@@ -1595,7 +1622,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1595
1622
|
branch: gitBranch(sessionCwd),
|
|
1596
1623
|
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
1597
1624
|
resumed: active?.resumed ?? false,
|
|
1598
|
-
mode: active?.mode ?? pendingMode ?? presets.defaultId,
|
|
1625
|
+
mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
|
|
1599
1626
|
permission,
|
|
1600
1627
|
dispatch,
|
|
1601
1628
|
steer,
|
package/src/permissions.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function effectivePermission(
|
|
|
24
24
|
session: Session | undefined,
|
|
25
25
|
pending: string | undefined,
|
|
26
26
|
): string {
|
|
27
|
-
return session === undefined ? pending ?? service.defaultPreset : service.current(session
|
|
27
|
+
return session === undefined ? pending ?? service.defaultPreset : service.current(session)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Validate a preset and write it only when a durable session already exists. */
|
package/src/presets.ts
CHANGED
|
@@ -21,15 +21,27 @@ export function isBlankSession(events: readonly SessionEvent[]): boolean {
|
|
|
21
21
|
return !events.some(event => event.type === 'turn/start')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Upstream renamed the shipped `code` preset to `ptc` in 0.1.2-rc.1; sessions
|
|
25
|
+
* and CLI choices recorded before the rename keep resolving through this map. */
|
|
26
|
+
const LEGACY_PRESET_IDS: Readonly<Record<string, string>> = { code: 'ptc' }
|
|
27
|
+
|
|
28
|
+
/** Translate a preset id recorded before an upstream rename to its current id. */
|
|
29
|
+
export function normalizePresetId(id: string): string
|
|
30
|
+
export function normalizePresetId(id: string | undefined): string | undefined
|
|
31
|
+
export function normalizePresetId(id: string | undefined): string | undefined {
|
|
32
|
+
return id === undefined ? undefined : LEGACY_PRESET_IDS[id] ?? id
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
/** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
|
|
25
|
-
export function resolvePreset(session: Pick<Session, 'header' | '
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
export function resolvePreset(session: Pick<Session, 'header' | 'snapshotEvents'>): string {
|
|
37
|
+
const events = session.snapshotEvents()
|
|
38
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
39
|
+
const event = events[index] as unknown as { type: string; data?: { agentPreset?: string } }
|
|
28
40
|
if (event.type === 'agent-preset/selected' && event.data?.agentPreset !== undefined) {
|
|
29
|
-
return event.data.agentPreset
|
|
41
|
+
return normalizePresetId(event.data.agentPreset) as string
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
|
-
return session.header.agentPreset ?? 'standard'
|
|
44
|
+
return normalizePresetId(session.header.agentPreset) ?? 'standard'
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/** Resolve a pre-session choice, or recompose an active blank Agent. */
|
|
@@ -38,6 +50,7 @@ export async function selectPreset(
|
|
|
38
50
|
agent: Agent | undefined,
|
|
39
51
|
presetId: string,
|
|
40
52
|
): Promise<PresetRow> {
|
|
53
|
+
presetId = normalizePresetId(presetId)
|
|
41
54
|
if (agent !== undefined) return switchPreset(service, agent, presetId)
|
|
42
55
|
const preset = await service.resolve(presetId)
|
|
43
56
|
if (preset.broken !== undefined) throw new Error(preset.broken)
|
|
@@ -50,7 +63,7 @@ export async function switchPreset(
|
|
|
50
63
|
agent: Agent,
|
|
51
64
|
presetId: string,
|
|
52
65
|
): Promise<PresetRow> {
|
|
53
|
-
if (!isBlankSession(agent.session.
|
|
66
|
+
if (!isBlankSession(agent.session.snapshotEvents())) {
|
|
54
67
|
throw new Error('mode is locked after the first turn; use /new <mode>')
|
|
55
68
|
}
|
|
56
69
|
const preset = await service.recompose(agent.ctx, presetId)
|
package/src/questions.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The terminal ask_user_question
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The terminal ask_user_question answerer: one `user-questions/request`
|
|
3
|
+
* waterfall listener that drives a FIFO queue — one question request on
|
|
4
|
+
* screen at a time, everything else waiting — then resolves the collected
|
|
5
|
+
* answers back into the waterfall. Mirrors the approval answerer's claim/
|
|
6
|
+
* defer split: only agents this TUI owns are answered, every other request
|
|
7
|
+
* falls through to the next answerer.
|
|
8
8
|
*
|
|
9
|
-
* Plan reviews (`exit_plan_mode`) arrive through the same
|
|
9
|
+
* Plan reviews (`exit_plan_mode`) arrive through the same waterfall with an
|
|
10
10
|
* `intent: { kind: 'plan-review' }` — the renderer highlights the approve
|
|
11
11
|
* option; the answer encoding is identical either way.
|
|
12
12
|
*
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
17
18
|
import {
|
|
18
19
|
UserQuestionError,
|
|
19
20
|
type AskUserQuestionAnswer,
|
|
@@ -56,13 +57,15 @@ const ABORT_ERROR = new UserQuestionError(
|
|
|
56
57
|
)
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Mount the
|
|
60
|
-
* @param ctx - context
|
|
61
|
-
* @
|
|
62
|
-
*
|
|
60
|
+
* Mount the `user-questions/request` answerer over a FIFO queue.
|
|
61
|
+
* @param ctx - plugin context whose event bus carries the waterfall.
|
|
62
|
+
* @param owns - agents this terminal answers for; every other request is
|
|
63
|
+
* deferred back into the waterfall (`next()`), so sibling answerers stay
|
|
64
|
+
* usable. Agent-less asks are claimed: this TUI is the only human surface
|
|
65
|
+
* in the process.
|
|
66
|
+
* @returns the store the renderer subscribes to.
|
|
63
67
|
*/
|
|
64
|
-
export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
65
|
-
const service = ctx.get('userQuestions')
|
|
68
|
+
export function mountQuestionProvider(ctx: Context, owns: (agent: Agent) => boolean): QuestionStore {
|
|
66
69
|
let snapshot: QuestionSnapshot = { pending: undefined }
|
|
67
70
|
let active: PendingQuestion | undefined
|
|
68
71
|
const queue: PendingQuestion[] = []
|
|
@@ -79,69 +82,49 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
79
82
|
set({ pending: next })
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
85
|
+
ctx.on('user-questions/request', (
|
|
86
|
+
request: AskUserQuestionRequest,
|
|
87
|
+
next: () => Promise<AskUserQuestionAnswer>,
|
|
88
|
+
): Promise<AskUserQuestionAnswer> => {
|
|
89
|
+
if (request.agent !== undefined && !owns(request.agent)) return next()
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
// Abort settles through the same channel as an Esc cancel: the
|
|
92
|
+
// owning tool/step died, so the answer must not linger.
|
|
93
|
+
const onAbort = (): void => {
|
|
94
|
+
if (active === pending) {
|
|
95
|
+
active = undefined
|
|
96
|
+
set({ pending: undefined })
|
|
97
|
+
advance()
|
|
98
|
+
} else {
|
|
99
|
+
const at = queue.indexOf(pending)
|
|
100
|
+
if (at >= 0) queue.splice(at, 1)
|
|
101
|
+
}
|
|
102
|
+
reject(ABORT_ERROR)
|
|
103
|
+
}
|
|
104
|
+
// Detach on every settle so an answered/cancelled question never
|
|
105
|
+
// retains a listener on the owning tool call's signal.
|
|
106
|
+
const detachAbort = (): void => {
|
|
107
|
+
if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
|
|
108
|
+
}
|
|
109
|
+
const pending: PendingQuestion = {
|
|
110
|
+
request,
|
|
111
|
+
resolve,
|
|
112
|
+
reject,
|
|
113
|
+
detachAbort,
|
|
114
|
+
}
|
|
115
|
+
if (request.signal?.aborted === true) {
|
|
116
|
+
reject(ABORT_ERROR)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
120
|
+
if (active === undefined) {
|
|
121
|
+
active = pending
|
|
122
|
+
set({ pending })
|
|
123
|
+
} else {
|
|
124
|
+
queue.push(pending)
|
|
101
125
|
}
|
|
102
|
-
}
|
|
103
|
-
service.registerProvider({
|
|
104
|
-
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
105
|
-
return new Promise((resolve, reject) => {
|
|
106
|
-
// Abort settles through the same channel as an Esc cancel: the
|
|
107
|
-
// owning tool/step died, so the answer must not linger.
|
|
108
|
-
const onAbort = (): void => {
|
|
109
|
-
if (active === pending) {
|
|
110
|
-
active = undefined
|
|
111
|
-
set({ pending: undefined })
|
|
112
|
-
advance()
|
|
113
|
-
} else {
|
|
114
|
-
const at = queue.indexOf(pending)
|
|
115
|
-
if (at >= 0) queue.splice(at, 1)
|
|
116
|
-
}
|
|
117
|
-
reject(ABORT_ERROR)
|
|
118
|
-
}
|
|
119
|
-
// Detach on every settle so an answered/cancelled question never
|
|
120
|
-
// retains a listener on the owning tool call's signal.
|
|
121
|
-
const detachAbort = (): void => {
|
|
122
|
-
if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
|
|
123
|
-
}
|
|
124
|
-
const pending: PendingQuestion = {
|
|
125
|
-
request,
|
|
126
|
-
resolve,
|
|
127
|
-
reject,
|
|
128
|
-
detachAbort,
|
|
129
|
-
}
|
|
130
|
-
if (request.signal?.aborted === true) {
|
|
131
|
-
reject(ABORT_ERROR)
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
135
|
-
if (active === undefined) {
|
|
136
|
-
active = pending
|
|
137
|
-
set({ pending })
|
|
138
|
-
} else {
|
|
139
|
-
queue.push(pending)
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
},
|
|
143
126
|
})
|
|
144
|
-
}
|
|
127
|
+
})
|
|
145
128
|
|
|
146
129
|
return {
|
|
147
130
|
subscribe(listener: () => void): () => void {
|