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/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",
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.1-rc.2",
67
- "@deepseek-ai/dsh-web-fetch-http": "0.1.1-rc.2",
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.1-rc.2",
78
- "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
79
- "@deepseek-ai/dsh-agent-default-model": "0.1.1-rc.2",
80
- "@deepseek-ai/dsh-agent-presets": "0.1.1-rc.2",
81
- "@deepseek-ai/dsh-cmdline": "0.1.1-rc.2",
82
- "@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.1-rc.2",
83
- "@deepseek-ai/dsh-commands": "0.1.1-rc.2",
84
- "@deepseek-ai/dsh-compaction": "0.1.1-rc.2",
85
- "@deepseek-ai/dsh-cordis-host-runner": "0.1.1-rc.2",
86
- "@deepseek-ai/dsh-credentials": "0.1.1-rc.2",
87
- "@deepseek-ai/dsh-file-reference-local": "0.1.1-rc.2",
88
- "@deepseek-ai/dsh-goal": "0.1.1-rc.2",
89
- "@deepseek-ai/dsh-invariants": "0.1.1-rc.2",
90
- "@deepseek-ai/dsh-jobs": "0.1.1-rc.2",
91
- "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
92
- "@deepseek-ai/dsh-llm-retry": "0.1.1-rc.2",
93
- "@deepseek-ai/dsh-permission-presets": "0.1.1-rc.2",
94
- "@deepseek-ai/dsh-plan-mode": "0.1.1-rc.2",
95
- "@deepseek-ai/dsh-sandbox-policy": "0.1.1-rc.2",
96
- "@deepseek-ai/dsh-session": "0.1.1-rc.2",
97
- "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2",
98
- "@deepseek-ai/dsh-session-reference": "0.1.1-rc.2",
99
- "@deepseek-ai/dsh-session-title": "0.1.1-rc.2",
100
- "@deepseek-ai/dsh-skill": "0.1.1-rc.2",
101
- "@deepseek-ai/dsh-user-approval": "0.1.1-rc.2",
102
- "@deepseek-ai/dsh-user-questions": "0.1.1-rc.2"
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.1-rc.2",
136
- "@deepseek-ai/dsh-attachment": "0.1.1-rc.2",
137
- "@deepseek-ai/dsh-compaction": "0.1.1-rc.2",
138
- "@deepseek-ai/dsh-commands": "0.1.1-rc.2",
139
- "@deepseek-ai/dsh-credentials": "0.1.1-rc.2",
140
- "@deepseek-ai/dsh-goal": "0.1.1-rc.2",
141
- "@deepseek-ai/dsh-jobs": "0.1.1-rc.2",
142
- "@deepseek-ai/dsh-llm-retry": "0.1.1-rc.2",
143
- "@deepseek-ai/dsh-permission-presets": "0.1.1-rc.2",
144
- "@deepseek-ai/dsh-plan-mode": "0.1.1-rc.2",
145
- "@deepseek-ai/dsh-sandbox-policy": "0.1.1-rc.2",
146
- "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2",
147
- "@deepseek-ai/dsh-session-reference": "0.1.1-rc.2",
148
- "@deepseek-ai/dsh-session-title": "0.1.1-rc.2",
149
- "@deepseek-ai/dsh-skill": "0.1.1-rc.2",
150
- "@deepseek-ai/dsh-user-approval": "0.1.1-rc.2",
151
- "@deepseek-ai/dsh-user-questions": "0.1.1-rc.2",
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-session'
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 {
@@ -1,4 +1,9 @@
1
- /** Read-only Git inspection used by /diff and /review. */
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 diff drivers.
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
- // An unborn repository has no HEAD. Preserve useful unstaged output for
71
- // the default form while still surfacing all other Git failures.
72
- if (argument.trim() !== '') throw error
73
- const text = await executeGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--'], signal)
74
- return { title: 'git diff - working tree', files: parseGitDiffFiles(text) }
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
- * Append one entry to the persistent file content: JSON line, capped to the
46
- * newest `max` entries with a trailing newline.
47
- * @param current - existing file content.
48
- * @param text - submission to persist.
49
- * @param max - entry cap.
50
- * @returns the new file content.
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 appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
53
- const entries = [...parseHistoryFile(current, max), text].slice(-max)
54
- return entries.map(serializeHistoryEntry).join('\n') + '\n'
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 per
74
- * entry, trailing newline). The runner writes the in-memory list as the whole
75
- * file, so rapid same-process submissions cannot lose entries to a
76
- * read-modify-write race (the file is never read back before writing).
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
- if (!next.resume) mode = (await presets.resolve(mode)).id
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.events),
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 provider: the single UI provider on the shared service,
594
- // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
595
- // through this same pipe.
596
- const questions: QuestionStore = mountQuestionProvider(ctx)
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
- inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
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
- /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
705
- let historyWriteChain: Promise<void> = Promise.resolve()
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(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
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.events, atSeq)
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,
@@ -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.events)
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' | 'events'>): string {
26
- for (let index = session.events.length - 1; index >= 0; index -= 1) {
27
- const event = session.events[index] as unknown as { type: string; data?: { agentPreset?: string } }
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.events)) {
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 provider: registers the single UI provider
3
- * on `ctx.userQuestions` and drives it with a FIFO queue — one question
4
- * request on screen at a time, everything else waiting — then resolves the
5
- * collected answers back into the tool's promise. The community TUI proved
6
- * this exact pipeline shape; here the dialog is an Ink bar instead of a
7
- * pi-tui inline modal.
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 service with an
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 single `ctx.userQuestions` UI provider over a FIFO queue.
60
- * @param ctx - context carrying the `userQuestions` service (dsh-base).
61
- * @returns the store the renderer subscribes to; a context without the
62
- * service yields a permanently empty store.
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
- if (service !== undefined) {
83
- // Capability guard against contract drift: the pinned release exposes
84
- // registerProvider, but an upstream alignment may replace it with the
85
- // 'user-questions/request' waterfall. Degrade to the permanently-empty
86
- // store (the no-service path) instead of failing startup with a
87
- // TypeError on a missing method.
88
- if (typeof (service as unknown as { registerProvider?: unknown }).registerProvider !== 'function') {
89
- return {
90
- subscribe(listener: () => void): () => void {
91
- listeners.add(listener)
92
- return () => {
93
- listeners.delete(listener)
94
- }
95
- },
96
- getSnapshot(): QuestionSnapshot {
97
- return snapshot
98
- },
99
- submit(): void {},
100
- cancel(): void {},
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 {