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/lib/index.mjs CHANGED
@@ -4,11 +4,11 @@ import { randomUUID } from "node:crypto";
4
4
  import * as fs from "node:fs";
5
5
  import { readFileSync, realpathSync } from "node:fs";
6
6
  import os, { homedir } from "node:os";
7
- import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
9
9
  import z from "@deepseek-ai/schemastery";
10
10
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
11
- import { MessageId, ReasoningEffortId, assertNever, boundContextSummary, createUserMessage, normalizeApiKey } from "@deepseek-ai/dsh-llm";
11
+ import { MessageId, ReasoningEffortId, boundContextSummary, createUserMessage, normalizeApiKey } from "@deepseek-ai/dsh-llm";
12
12
  import { SessionId } from "@deepseek-ai/dsh-session";
13
13
  import { PassThrough, Stream } from "node:stream";
14
14
  import process$1, { cwd, env } from "node:process";
@@ -22,6 +22,7 @@ import { AuthorizationDeclinedError } from "@deepseek-ai/dsh-authorization";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
24
24
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
25
+ import { assertNever } from "@deepseek-ai/dsh-util-values";
25
26
  //#region node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
26
27
  /**
27
28
  * @license React
@@ -30061,6 +30062,26 @@ function parseHistoryFile(raw, max = 100) {
30061
30062
  return kept.slice(-max);
30062
30063
  }
30063
30064
  /**
30065
+ * The append unit for the persistent file: one JSON line, so a multi-line
30066
+ * draft still occupies exactly one physical line. Each submission appends
30067
+ * this unit at the end of the file, so concurrent terminals add entries
30068
+ * after each other. Node chunks one append at 512 KiB: a pasted entry
30069
+ * beyond that size could interleave mid-line with another writer's
30070
+ * chunks, and the damaged line then drops out at the next parse —
30071
+ * recall tolerates the loss by design.
30072
+ */
30073
+ function historyLine(text) {
30074
+ return serializeHistoryEntry(text) + "\n";
30075
+ }
30076
+ /**
30077
+ * Whether the file on disk differs from its canonical form (deduped and
30078
+ * capped). True means stale lines have accumulated and the next boot
30079
+ * should rewrite it once, atomically.
30080
+ */
30081
+ function needsCompaction(raw, max = 100) {
30082
+ return serializeHistoryList(parseHistoryFile(raw, max)) !== raw;
30083
+ }
30084
+ /**
30064
30085
  * Record one in-session submission: empty text is ignored and an adjacent
30065
30086
  * duplicate collapses (Codex `record_local_submission` semantics). The local
30066
30087
  * pool shares the persistent pool's cap so the recall space stays bounded.
@@ -30075,10 +30096,10 @@ function recordLocalEntry(local, text, max = 100) {
30075
30096
  return [...local, text].slice(-max);
30076
30097
  }
30077
30098
  /**
30078
- * Serialize a capped entry list to the history file format (one JSON line per
30079
- * entry, trailing newline). The runner writes the in-memory list as the whole
30080
- * file, so rapid same-process submissions cannot lose entries to a
30081
- * read-modify-write race (the file is never read back before writing).
30099
+ * Serialize a capped entry list to the history file format (one JSON line
30100
+ * per entry, trailing newline). The boot-time compaction writes this
30101
+ * canonical form once when stale lines have accumulated; submissions
30102
+ * themselves only ever append a single line.
30082
30103
  * @param entries - the entries to persist, oldest first.
30083
30104
  * @returns the file content, '' for an empty list.
30084
30105
  */
@@ -35789,13 +35810,15 @@ async function syncModelCapabilities(ctx, notify) {
35789
35810
  //#region src/questions.ts
35790
35811
  const ABORT_ERROR = new UserQuestionError("ask_user_question was interrupted before the user answered", "ASK_ABORTED");
35791
35812
  /**
35792
- * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
35793
- * @param ctx - context carrying the `userQuestions` service (dsh-base).
35794
- * @returns the store the renderer subscribes to; a context without the
35795
- * service yields a permanently empty store.
35813
+ * Mount the `user-questions/request` answerer over a FIFO queue.
35814
+ * @param ctx - plugin context whose event bus carries the waterfall.
35815
+ * @param owns - agents this terminal answers for; every other request is
35816
+ * deferred back into the waterfall (`next()`), so sibling answerers stay
35817
+ * usable. Agent-less asks are claimed: this TUI is the only human surface
35818
+ * in the process.
35819
+ * @returns the store the renderer subscribes to.
35796
35820
  */
35797
- function mountQuestionProvider(ctx) {
35798
- const service = ctx.get("userQuestions");
35821
+ function mountQuestionProvider(ctx, owns) {
35799
35822
  let snapshot = { pending: void 0 };
35800
35823
  let active;
35801
35824
  const queue = [];
@@ -35810,54 +35833,40 @@ function mountQuestionProvider(ctx) {
35810
35833
  active = next;
35811
35834
  set({ pending: next });
35812
35835
  };
35813
- if (service !== void 0) {
35814
- if (typeof service.registerProvider !== "function") return {
35815
- subscribe(listener) {
35816
- listeners.add(listener);
35817
- return () => {
35818
- listeners.delete(listener);
35819
- };
35820
- },
35821
- getSnapshot() {
35822
- return snapshot;
35823
- },
35824
- submit() {},
35825
- cancel() {}
35826
- };
35827
- service.registerProvider({ ask(request) {
35828
- return new Promise((resolve, reject) => {
35829
- const onAbort = () => {
35830
- if (active === pending) {
35831
- active = void 0;
35832
- set({ pending: void 0 });
35833
- advance();
35834
- } else {
35835
- const at = queue.indexOf(pending);
35836
- if (at >= 0) queue.splice(at, 1);
35837
- }
35838
- reject(ABORT_ERROR);
35839
- };
35840
- const detachAbort = () => {
35841
- if (request.signal !== void 0) request.signal.removeEventListener("abort", onAbort);
35842
- };
35843
- const pending = {
35844
- request,
35845
- resolve,
35846
- reject,
35847
- detachAbort
35848
- };
35849
- if (request.signal?.aborted === true) {
35850
- reject(ABORT_ERROR);
35851
- return;
35836
+ ctx.on("user-questions/request", (request, next) => {
35837
+ if (request.agent !== void 0 && !owns(request.agent)) return next();
35838
+ return new Promise((resolve, reject) => {
35839
+ const onAbort = () => {
35840
+ if (active === pending) {
35841
+ active = void 0;
35842
+ set({ pending: void 0 });
35843
+ advance();
35844
+ } else {
35845
+ const at = queue.indexOf(pending);
35846
+ if (at >= 0) queue.splice(at, 1);
35852
35847
  }
35853
- request.signal?.addEventListener("abort", onAbort, { once: true });
35854
- if (active === void 0) {
35855
- active = pending;
35856
- set({ pending });
35857
- } else queue.push(pending);
35858
- });
35859
- } });
35860
- }
35848
+ reject(ABORT_ERROR);
35849
+ };
35850
+ const detachAbort = () => {
35851
+ if (request.signal !== void 0) request.signal.removeEventListener("abort", onAbort);
35852
+ };
35853
+ const pending = {
35854
+ request,
35855
+ resolve,
35856
+ reject,
35857
+ detachAbort
35858
+ };
35859
+ if (request.signal?.aborted === true) {
35860
+ reject(ABORT_ERROR);
35861
+ return;
35862
+ }
35863
+ request.signal?.addEventListener("abort", onAbort, { once: true });
35864
+ if (active === void 0) {
35865
+ active = pending;
35866
+ set({ pending });
35867
+ } else queue.push(pending);
35868
+ });
35869
+ });
35861
35870
  return {
35862
35871
  subscribe(listener) {
35863
35872
  listeners.add(listener);
@@ -36633,7 +36642,12 @@ function selectForkSeed(events, atSeq) {
36633
36642
  }
36634
36643
  //#endregion
36635
36644
  //#region src/git-workflow.ts
36636
- /** Read-only Git inspection used by /diff and /review. */
36645
+ /**
36646
+ * Read-only Git inspection used by /diff and /review. Every diff
36647
+ * invocation carries --no-ext-diff and --no-textconv, so configured
36648
+ * external diff drivers and text converters can never execute as a
36649
+ * side effect of reading a diff.
36650
+ */
36637
36651
  /** Split Git's stable `diff --git` framing without interpreting patch content. */
36638
36652
  function parseGitDiffFiles(text) {
36639
36653
  if (text === "") return [];
@@ -36656,6 +36670,7 @@ function parseGitDiffSpec(argument) {
36656
36670
  args: [
36657
36671
  "diff",
36658
36672
  "--no-ext-diff",
36673
+ "--no-textconv",
36659
36674
  "--unified=3",
36660
36675
  "HEAD",
36661
36676
  "--"
@@ -36666,6 +36681,7 @@ function parseGitDiffSpec(argument) {
36666
36681
  args: [
36667
36682
  "diff",
36668
36683
  "--no-ext-diff",
36684
+ "--no-textconv",
36669
36685
  "--unified=3",
36670
36686
  "--cached",
36671
36687
  "--"
@@ -36677,6 +36693,7 @@ function parseGitDiffSpec(argument) {
36677
36693
  args: [
36678
36694
  "diff",
36679
36695
  "--no-ext-diff",
36696
+ "--no-textconv",
36680
36697
  "--unified=3",
36681
36698
  value,
36682
36699
  "--"
@@ -36700,8 +36717,25 @@ function executeGit(cwd, args, signal) {
36700
36717
  });
36701
36718
  });
36702
36719
  }
36720
+ /** Arguments for the unstaged-only fallback below. */
36721
+ const UNSTAGED_DIFF_ARGS = [
36722
+ "diff",
36723
+ "--no-ext-diff",
36724
+ "--no-textconv",
36725
+ "--unified=3",
36726
+ "--"
36727
+ ];
36728
+ /** Whether the repository has at least one commit (a HEAD revision). */
36729
+ function hasHeadRevision(cwd, signal) {
36730
+ return executeGit(cwd, [
36731
+ "rev-parse",
36732
+ "--verify",
36733
+ "--quiet",
36734
+ "HEAD"
36735
+ ], signal).then(() => true).catch(() => false);
36736
+ }
36703
36737
  /**
36704
- * Load one complete textual diff without invoking external diff drivers.
36738
+ * Load one complete textual diff without invoking external programs.
36705
36739
  * @param signal - aborted by the caller on session switches/quit, killing the
36706
36740
  * git subprocess instead of letting a stale repository's diff land later.
36707
36741
  */
@@ -36714,15 +36748,10 @@ async function loadGitDiff(cwd, argument, signal) {
36714
36748
  files: parseGitDiffFiles(text)
36715
36749
  };
36716
36750
  } catch (error) {
36717
- if (argument.trim() !== "") throw error;
36751
+ if (argument.trim() !== "" || signal?.aborted === true || await hasHeadRevision(cwd, signal)) throw error;
36718
36752
  return {
36719
- title: "git diff - working tree",
36720
- files: parseGitDiffFiles(await executeGit(cwd, [
36721
- "diff",
36722
- "--no-ext-diff",
36723
- "--unified=3",
36724
- "--"
36725
- ], signal))
36753
+ title: "git diff - working tree (no commits yet)",
36754
+ files: parseGitDiffFiles(await executeGit(cwd, UNSTAGED_DIFF_ARGS, signal))
36726
36755
  };
36727
36756
  }
36728
36757
  }
@@ -36797,16 +36826,24 @@ function agentPresetsFrom(ctx) {
36797
36826
  function isBlankSession(events) {
36798
36827
  return !events.some((event) => event.type === "turn/start");
36799
36828
  }
36829
+ /** Upstream renamed the shipped `code` preset to `ptc` in 0.1.2-rc.1; sessions
36830
+ * and CLI choices recorded before the rename keep resolving through this map. */
36831
+ const LEGACY_PRESET_IDS = { code: "ptc" };
36832
+ function normalizePresetId(id) {
36833
+ return id === void 0 ? void 0 : LEGACY_PRESET_IDS[id] ?? id;
36834
+ }
36800
36835
  /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
36801
36836
  function resolvePreset(session) {
36802
- for (let index = session.events.length - 1; index >= 0; index -= 1) {
36803
- const event = session.events[index];
36804
- if (event.type === "agent-preset/selected" && event.data?.agentPreset !== void 0) return event.data.agentPreset;
36837
+ const events = session.snapshotEvents();
36838
+ for (let index = events.length - 1; index >= 0; index -= 1) {
36839
+ const event = events[index];
36840
+ if (event.type === "agent-preset/selected" && event.data?.agentPreset !== void 0) return normalizePresetId(event.data.agentPreset);
36805
36841
  }
36806
- return session.header.agentPreset ?? "standard";
36842
+ return normalizePresetId(session.header.agentPreset) ?? "standard";
36807
36843
  }
36808
36844
  /** Resolve a pre-session choice, or recompose an active blank Agent. */
36809
36845
  async function selectPreset(service, agent, presetId) {
36846
+ presetId = normalizePresetId(presetId);
36810
36847
  if (agent !== void 0) return switchPreset(service, agent, presetId);
36811
36848
  const preset = await service.resolve(presetId);
36812
36849
  if (preset.broken !== void 0) throw new Error(preset.broken);
@@ -36814,7 +36851,7 @@ async function selectPreset(service, agent, presetId) {
36814
36851
  }
36815
36852
  /** Recompose atomically from the caller's perspective, logging only success. */
36816
36853
  async function switchPreset(service, agent, presetId) {
36817
- if (!isBlankSession(agent.session.events)) throw new Error("mode is locked after the first turn; use /new <mode>");
36854
+ if (!isBlankSession(agent.session.snapshotEvents())) throw new Error("mode is locked after the first turn; use /new <mode>");
36818
36855
  const preset = await service.recompose(agent.ctx, presetId);
36819
36856
  agent.session.append("agent-preset/selected", { agentPreset: preset.id });
36820
36857
  return preset;
@@ -36827,7 +36864,7 @@ function permissionPresetsFrom(ctx) {
36827
36864
  }
36828
36865
  /** Effective label for either an active session or the not-yet-created first one. */
36829
36866
  function effectivePermission(service, session, pending) {
36830
- return session === void 0 ? pending ?? service.defaultPreset : service.current(session.events);
36867
+ return session === void 0 ? pending ?? service.defaultPreset : service.current(session);
36831
36868
  }
36832
36869
  /** Validate a preset and write it only when a durable session already exists. */
36833
36870
  function selectPermission(service, session, preset) {
@@ -36909,6 +36946,38 @@ function listPluginRows(ctx) {
36909
36946
  * @module @deepseek-ai/dsh-code/settings-file
36910
36947
  */
36911
36948
  /**
36949
+ * Run one file operation with a bounded retry: one initial try plus at
36950
+ * most `retries` more. Creating or replacing a file can fail transiently
36951
+ * with EPERM/EACCES while an antivirus scanner or search indexer holds
36952
+ * it — the standard graceful-fs remedy, not a workaround for a
36953
+ * persistent permission problem. A save that still fails leaves its
36954
+ * uniquely named temp file behind, so repeated crashed saves accumulate
36955
+ * distinct leftovers rather than corrupting a shared one.
36956
+ */
36957
+ async function withTransientRetry(operation, retries = 5) {
36958
+ for (let attempt = 0;; attempt += 1) try {
36959
+ await operation();
36960
+ return;
36961
+ } catch (error) {
36962
+ const code = error.code;
36963
+ if (attempt >= retries || code !== "EPERM" && code !== "EACCES") throw error;
36964
+ await new Promise((resolve) => setTimeout(resolve, 30 * (attempt + 1)));
36965
+ }
36966
+ }
36967
+ /**
36968
+ * Write one file atomically: create the parent directory, write to a
36969
+ * uniquely named temp file, and rename it into place. A crash midway
36970
+ * can never leave a half-written document behind. Unique temp names
36971
+ * keep concurrent writers (two terminals, two chains in one process)
36972
+ * from sharing one temp path.
36973
+ */
36974
+ async function writeFileAtomically(path, text) {
36975
+ await mkdir(dirname(path), { recursive: true });
36976
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
36977
+ await withTransientRetry(() => writeFile(temp, text, "utf8"));
36978
+ await withTransientRetry(() => rename(temp, path));
36979
+ }
36980
+ /**
36912
36981
  * Create the shared settings-write chain. One instance per process keeps
36913
36982
  * every user-level JSON file mutually serialized.
36914
36983
  * @returns the persistence handle.
@@ -36917,12 +36986,7 @@ function createUserSettingsPersistence() {
36917
36986
  let chain = Promise.resolve();
36918
36987
  return {
36919
36988
  save(path, text) {
36920
- const write = chain.then(async () => {
36921
- await mkdir(dirname(path), { recursive: true });
36922
- const temp = `${path}.tmp`;
36923
- await writeFile(temp, text, "utf8");
36924
- await rename(temp, path);
36925
- });
36989
+ const write = chain.then(() => writeFileAtomically(path, text));
36926
36990
  chain = write.catch(() => {});
36927
36991
  return write;
36928
36992
  },
@@ -37161,7 +37225,7 @@ async function run(ctx, startup, io) {
37161
37225
  const nextCwd = next.cwd ?? cwd;
37162
37226
  const selectionState = pendingSelection === void 0 ? {} : { picked: pendingSelection };
37163
37227
  let mode = next.resume ? next.mode : next.mode ?? pendingMode;
37164
- if (!next.resume) mode = (await presets.resolve(mode)).id;
37228
+ if (!next.resume) mode = (await presets.resolve(normalizePresetId(mode ?? presets.defaultId))).id;
37165
37229
  const setup = async (agentCtx) => {
37166
37230
  const sessionPreset = next.resume ? resolvePreset(agentCtx.agent.session) : mode;
37167
37231
  mode = (await presets.mount(agentCtx, sessionPreset)).id;
@@ -37206,7 +37270,7 @@ async function run(ctx, startup, io) {
37206
37270
  handle,
37207
37271
  agent: handle.agent,
37208
37272
  session,
37209
- store: createTranscriptStore(session.events),
37273
+ store: createTranscriptStore(session.snapshotEvents()),
37210
37274
  mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
37211
37275
  mode: mode ?? "standard",
37212
37276
  selection: selectionState,
@@ -37290,7 +37354,7 @@ async function run(ctx, startup, io) {
37290
37354
  const picked = subagentOverride ?? resolveEffectiveSelection(belongsToActive && activeAgent !== void 0 ? activeAgent.selection.picked ?? pendingSelection : void 0, subject.session.requestHeader()?.config, currentDefaults());
37291
37355
  return next().then((resolved) => applyModelSelectionToConfig(resolved, picked));
37292
37356
  });
37293
- const questions = mountQuestionProvider(ctx);
37357
+ const questions = mountQuestionProvider(ctx, (candidate) => agent !== void 0 && candidate.id === agent.id);
37294
37358
  const bridge = { notify: () => {} };
37295
37359
  const capabilitySyncDebounceMs = 400;
37296
37360
  const runCapabilitySync = () => {
@@ -37348,17 +37412,27 @@ async function run(ctx, startup, io) {
37348
37412
  };
37349
37413
  const historyPath = join(homedir(), ".dsh", "dsh-code", "history.jsonl");
37350
37414
  let inputHistory = [];
37415
+ let historyWriteChain = Promise.resolve();
37351
37416
  try {
37352
- inputHistory = parseHistoryFile(readFileSync(historyPath, "utf8"));
37417
+ const rawHistory = readFileSync(historyPath, "utf8");
37418
+ inputHistory = parseHistoryFile(rawHistory);
37419
+ if (needsCompaction(rawHistory)) historyWriteChain = historyWriteChain.then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory))).catch(() => {});
37353
37420
  } catch {
37354
37421
  inputHistory = [];
37355
37422
  }
37356
- /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
37357
- let historyWriteChain = Promise.resolve();
37423
+ /**
37424
+ * Serialized history writes: each submission appends one JSON line at the
37425
+ * end of the file, so concurrent terminals add entries after each other
37426
+ * instead of overwriting snapshots they read at their own boot. A
37427
+ * multi-line draft still occupies one physical line (JSON escapes the
37428
+ * newline), and a regular-length line reaches the disk as one positioned
37429
+ * write; an oversized paste may interleave mid-line, which the next
37430
+ * parse simply drops.
37431
+ */
37358
37432
  const recordHistory = (text) => {
37359
37433
  if (text === "") return;
37360
37434
  inputHistory = [...inputHistory, text].slice(-100);
37361
- historyWriteChain = historyWriteChain.then(() => mkdir(dirname(historyPath), { recursive: true })).then(() => writeFile(historyPath, serializeHistoryList(inputHistory), "utf8")).catch((writeError) => {
37435
+ historyWriteChain = historyWriteChain.then(() => mkdir(dirname(historyPath), { recursive: true })).then(() => appendFile(historyPath, historyLine(text), "utf8")).catch((writeError) => {
37362
37436
  bridge.notify("history save failed: " + (writeError instanceof Error ? writeError.message : String(writeError)), "error");
37363
37437
  });
37364
37438
  };
@@ -37977,10 +38051,6 @@ async function run(ctx, startup, io) {
37977
38051
  };
37978
38052
  const reviewChanges = (argument) => {
37979
38053
  const currentAgent = agent;
37980
- if (currentAgent === void 0) {
37981
- bridge.notify("no session yet - submit a message to start", "warning");
37982
- return;
37983
- }
37984
38054
  const atEpoch = epoch;
37985
38055
  const reviewCwd = session?.header.cwd ?? cwd;
37986
38056
  const controller = new AbortController();
@@ -38014,7 +38084,7 @@ async function run(ctx, startup, io) {
38014
38084
  const text = argument.trim();
38015
38085
  const atSeq = text === "" ? void 0 : Number(text);
38016
38086
  if (text !== "" && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) throw new Error("usage: /fork [event-seq]");
38017
- const seed = selectForkSeed(session.events, atSeq);
38087
+ const seed = selectForkSeed(session.snapshotEvents(), atSeq);
38018
38088
  const id = `session-${randomUUID()}`;
38019
38089
  requestSwitch({
38020
38090
  target: {
@@ -38070,7 +38140,7 @@ async function run(ctx, startup, io) {
38070
38140
  branch: gitBranch(sessionCwd),
38071
38141
  sessionId: session === void 0 ? "" : session.id.slice(-8),
38072
38142
  resumed: active?.resumed ?? false,
38073
- mode: active?.mode ?? pendingMode ?? presets.defaultId,
38143
+ mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
38074
38144
  permission,
38075
38145
  dispatch,
38076
38146
  steer,
@@ -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
  export interface GitDiffSpec {
3
8
  readonly label: string;
4
9
  readonly args: readonly string[];
@@ -18,7 +23,7 @@ export declare function parseGitDiffFiles(text: string): readonly GitDiffFile[];
18
23
  /** Parse the intentionally small, option-safe /diff argument vocabulary. */
19
24
  export declare function parseGitDiffSpec(argument: string): GitDiffSpec;
20
25
  /**
21
- * Load one complete textual diff without invoking external diff drivers.
26
+ * Load one complete textual diff without invoking external programs.
22
27
  * @param signal - aborted by the caller on session switches/quit, killing the
23
28
  * git subprocess instead of letting a stale repository's diff land later.
24
29
  */
@@ -21,14 +21,21 @@ export declare function serializeHistoryEntry(text: string): string;
21
21
  */
22
22
  export declare function parseHistoryFile(raw: string, max?: number): readonly string[];
23
23
  /**
24
- * Append one entry to the persistent file content: JSON line, capped to the
25
- * newest `max` entries with a trailing newline.
26
- * @param current - existing file content.
27
- * @param text - submission to persist.
28
- * @param max - entry cap.
29
- * @returns the new file content.
24
+ * The append unit for the persistent file: one JSON line, so a multi-line
25
+ * draft still occupies exactly one physical line. Each submission appends
26
+ * this unit at the end of the file, so concurrent terminals add entries
27
+ * after each other. Node chunks one append at 512 KiB: a pasted entry
28
+ * beyond that size could interleave mid-line with another writer's
29
+ * chunks, and the damaged line then drops out at the next parse —
30
+ * recall tolerates the loss by design.
31
+ */
32
+ export declare function historyLine(text: string): string;
33
+ /**
34
+ * Whether the file on disk differs from its canonical form (deduped and
35
+ * capped). True means stale lines have accumulated and the next boot
36
+ * should rewrite it once, atomically.
30
37
  */
31
- export declare function appendHistoryContent(current: string, text: string, max?: number): string;
38
+ export declare function needsCompaction(raw: string, max?: number): boolean;
32
39
  /**
33
40
  * Record one in-session submission: empty text is ignored and an adjacent
34
41
  * duplicate collapses (Codex `record_local_submission` semantics). The local
@@ -40,10 +47,10 @@ export declare function appendHistoryContent(current: string, text: string, max?
40
47
  */
41
48
  export declare function recordLocalEntry(local: readonly string[], text: string, max?: number): readonly string[];
42
49
  /**
43
- * Serialize a capped entry list to the history file format (one JSON line per
44
- * entry, trailing newline). The runner writes the in-memory list as the whole
45
- * file, so rapid same-process submissions cannot lose entries to a
46
- * read-modify-write race (the file is never read back before writing).
50
+ * Serialize a capped entry list to the history file format (one JSON line
51
+ * per entry, trailing newline). The boot-time compaction writes this
52
+ * canonical form once when stale lines have accumulated; submissions
53
+ * themselves only ever append a single line.
47
54
  * @param entries - the entries to persist, oldest first.
48
55
  * @returns the file content, '' for an empty list.
49
56
  */
@@ -11,8 +11,11 @@ export type AgentPresetsService = AgentPresets;
11
11
  export declare function agentPresetsFrom(ctx: Context): AgentPresetsService | undefined;
12
12
  /** A preset may change only before the first durable turn begins. */
13
13
  export declare function isBlankSession(events: readonly SessionEvent[]): boolean;
14
+ /** Translate a preset id recorded before an upstream rename to its current id. */
15
+ export declare function normalizePresetId(id: string): string;
16
+ export declare function normalizePresetId(id: string | undefined): string | undefined;
14
17
  /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
15
- export declare function resolvePreset(session: Pick<Session, 'header' | 'events'>): string;
18
+ export declare function resolvePreset(session: Pick<Session, 'header' | 'snapshotEvents'>): string;
16
19
  /** Resolve a pre-session choice, or recompose an active blank Agent. */
17
20
  export declare function selectPreset(service: AgentPresetsService, agent: Agent | undefined, presetId: string): Promise<PresetRow>;
18
21
  /** Recompose atomically from the caller's perspective, logging only success. */
@@ -1,18 +1,19 @@
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
  *
13
13
  * @module @deepseek-ai/dsh-code/questions
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
+ import type { Agent } from '@deepseek-ai/dsh-agent';
16
17
  import { type AskUserQuestionAnswer, type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions';
17
18
  /** One question request waiting on the human, with its settle channels. */
18
19
  export interface PendingQuestion {
@@ -42,9 +43,12 @@ export interface QuestionStore {
42
43
  cancel(pending: PendingQuestion): void;
43
44
  }
44
45
  /**
45
- * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
46
- * @param ctx - context carrying the `userQuestions` service (dsh-base).
47
- * @returns the store the renderer subscribes to; a context without the
48
- * service yields a permanently empty store.
46
+ * Mount the `user-questions/request` answerer over a FIFO queue.
47
+ * @param ctx - plugin context whose event bus carries the waterfall.
48
+ * @param owns - agents this terminal answers for; every other request is
49
+ * deferred back into the waterfall (`next()`), so sibling answerers stay
50
+ * usable. Agent-less asks are claimed: this TUI is the only human surface
51
+ * in the process.
52
+ * @returns the store the renderer subscribes to.
49
53
  */
50
- export declare function mountQuestionProvider(ctx: Context): QuestionStore;
54
+ export declare function mountQuestionProvider(ctx: Context, owns: (agent: Agent) => boolean): QuestionStore;
@@ -7,7 +7,8 @@
7
7
  * @module @deepseek-ai/dsh-tui/render/projection
8
8
  */
9
9
  import { type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm';
10
- import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session';
10
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
11
+ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo';
11
12
  import { type ToolDetail } from './tool-detail.ts';
12
13
  /** One user prompt line. */
13
14
  export interface UserEntry {
@@ -15,6 +15,14 @@
15
15
  *
16
16
  * @module @deepseek-ai/dsh-code/settings-file
17
17
  */
18
+ /**
19
+ * Write one file atomically: create the parent directory, write to a
20
+ * uniquely named temp file, and rename it into place. A crash midway
21
+ * can never leave a half-written document behind. Unique temp names
22
+ * keep concurrent writers (two terminals, two chains in one process)
23
+ * from sharing one temp path.
24
+ */
25
+ export declare function writeFileAtomically(path: string, text: string): Promise<void>;
18
26
  /** The serialized persistence surface; flush() is handed to the quit sequence. */
19
27
  export interface UserSettingsPersistence {
20
28
  /**