@xbzbing/dsh-git-panel 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,10 @@ function planAction(action, unborn) {
36
36
  }
37
37
  case "branch-checkout": {
38
38
  if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
39
- let checkout = ["git", "checkout", "--end-of-options", action.name];
39
+ let checkout = ["git", "checkout", action.name];
40
40
  if (action.createFrom !== void 0) {
41
41
  if (!isSafeBranchName(action.createFrom)) return { error: "invalid-name", message: `unsafe tracking ref: ${action.createFrom}` };
42
- checkout = ["git", "checkout", "-b", action.name, "--track", "--end-of-options", action.createFrom];
42
+ checkout = ["git", "checkout", "-b", action.name, "--track", action.createFrom];
43
43
  }
44
44
  const msg = action.stashMessage?.trim() ?? "";
45
45
  return { argv: msg === "" ? [checkout] : [["git", "stash", "push", "-u", "-m", msg], checkout] };
@@ -85,7 +85,7 @@ function planAction(action, unborn) {
85
85
  if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
86
86
  return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
87
87
  }
88
- return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
88
+ return { argv: [["git", "reset", `--${action.mode}`, action.commit]] };
89
89
  }
90
90
  }
91
91
  }
@@ -211,6 +211,26 @@ async function runAction(deps, config, request) {
211
211
  }
212
212
  return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
213
213
  }
214
+ async function looksLikeRepository(deps, target) {
215
+ try {
216
+ await deps.fs.stat(join(target, ".git"));
217
+ return true;
218
+ } catch {
219
+ }
220
+ const head = await pathKind(deps, join(target, "HEAD"));
221
+ const refs = await pathKind(deps, join(target, "refs"));
222
+ const objects = await pathKind(deps, join(target, "objects"));
223
+ if (!head.present || head.directory === true) return false;
224
+ const directoryLike = (kind) => kind.present && kind.directory !== false;
225
+ return directoryLike(refs) && directoryLike(objects);
226
+ }
227
+ async function pathKind(deps, path) {
228
+ try {
229
+ return { present: true, directory: (await deps.fs.stat(path)).isDirectory() };
230
+ } catch {
231
+ return { present: await deps.fs.symlinkTarget(path) !== null, directory: null };
232
+ }
233
+ }
214
234
  async function discardUntracked(deps, config, root, sessionId, paths) {
215
235
  const snap = await snapshotForSession(deps, config, sessionId);
216
236
  if (!snap.ok) return null;
@@ -218,12 +238,20 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
218
238
  const untracked = paths.filter((p) => untrackedSet.has(p));
219
239
  if (untracked.length === 0) return null;
220
240
  const tracked = paths.filter((p) => !untrackedSet.has(p));
241
+ const directoryEntries = new Set(snap.value.changes.filter((c) => c.isDirectory === true).map((c) => c.path));
242
+ for (const path of untracked) {
243
+ if (!directoryEntries.has(path)) continue;
244
+ if (!await looksLikeRepository(deps, join(root, path))) continue;
245
+ return { ok: false, result: { ok: false, error: { code: "nested-repo", message: `repository directory: ${path}` } } };
246
+ }
221
247
  let rootReal;
222
248
  try {
223
249
  rootReal = await deps.fs.realpath(root);
224
250
  } catch {
225
251
  return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
226
252
  }
253
+ const failures = [];
254
+ let removedCount = 0;
227
255
  for (const path of untracked) {
228
256
  if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
229
257
  const target = join(root, path);
@@ -236,8 +264,24 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
236
264
  if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
237
265
  return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
238
266
  }
239
- await deps.fs.remove(target).catch(() => {
240
- });
267
+ if (await looksLikeRepository(deps, target)) {
268
+ const code = removedCount === 0 ? "nested-repo" : "nested-repo-partial";
269
+ const detail = removedCount === 0 ? "" : `; ${removedCount} path(s) already removed`;
270
+ return { ok: false, result: { ok: false, error: { code, message: `repository directory: ${path}${detail}` } } };
271
+ }
272
+ try {
273
+ await deps.fs.remove(target);
274
+ removedCount += 1;
275
+ } catch (e) {
276
+ failures.push(`${path}: ${e instanceof Error ? e.message : String(e)}`);
277
+ }
278
+ }
279
+ if (failures.length > 0) {
280
+ const skipped = tracked.length > 0 ? `; ${tracked.length} tracked path(s) not restored` : "";
281
+ return {
282
+ ok: false,
283
+ result: { ok: false, error: { code: "git-error", message: `could not discard ${failures.length} path(s): ${failures.join("; ")}${skipped}` } }
284
+ };
241
285
  }
242
286
  return { ok: true, remainingTracked: tracked };
243
287
  }
@@ -1,5 +1,5 @@
1
1
  import type { GitRunner } from './git.js';
2
- import type { AgentDefaultModelFace, LlmFace } from './llm-face.js';
2
+ import type { AgentDefaultModelFace, LlmFace, SessionModelSelection } from './llm-face.js';
3
3
  import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult, SuggestLanguage } from './types.js';
4
4
  export interface GitPanelConfig {
5
5
  readonly timeoutMs: number;
@@ -26,6 +26,9 @@ export interface GitPanelConfig {
26
26
  /** Optional provider/model route override; absent → the agent default. */
27
27
  readonly suggestProvider?: string;
28
28
  readonly suggestModel?: string;
29
+ /** Adapter-owned effort for the fixed route; absent → omit from the request
30
+ * (the model / adapter default applies). Ignored when the route is not fixed. */
31
+ readonly suggestReasoningEffort?: string;
29
32
  }
30
33
  export declare const DEFAULT_CONFIG: GitPanelConfig;
31
34
  export declare function normalizeConfig(raw: unknown): GitPanelConfig;
@@ -50,9 +53,11 @@ export interface SnapshotDeps {
50
53
  readonly run: GitRunner;
51
54
  readonly fs: {
52
55
  realpath(path: string): Promise<string>;
56
+ /** `isDirectory` reports the *resolved* type (symlinks are followed). */
53
57
  stat(path: string): Promise<{
54
58
  mtimeMs: number;
55
59
  size: number;
60
+ isDirectory(): boolean;
56
61
  }>;
57
62
  /** Raw bytes (no encoding) — image sides for the image-diff query. */
58
63
  readFile(path: string): Promise<Buffer>;
@@ -67,7 +72,7 @@ export interface SnapshotDeps {
67
72
  name: string;
68
73
  isDirectory: boolean;
69
74
  }>>;
70
- /** Best-effort unlink (force) — temp-blob cleanup. */
75
+ /** Recursive remove (force) — untracked discards pass whole directories; temp-blob cleanup passes files. */
71
76
  remove(path: string): Promise<void>;
72
77
  };
73
78
  readonly sessions: {
@@ -92,14 +97,17 @@ export interface SnapshotDeps {
92
97
  */
93
98
  readonly rootNegCache?: Map<string, number>;
94
99
  /**
95
- * Optional per-request resolvers for the host `llm` and `agentDefaultModel`
96
- * services (see llm-face.ts). Resolved on every suggest call — never frozen
97
- * at construction — so a provider fiber that activates late or restarts is
100
+ * Optional per-request resolvers for the host model services (see
101
+ * llm-face.ts). Resolved on every suggest call — never frozen at
102
+ * construction — so a provider fiber that activates late or restarts is
98
103
  * picked up. Absent in deployments without a model backend; the suggest
99
104
  * endpoint then reports `llm-unavailable` instead of failing activation.
100
105
  */
101
106
  readonly getLlm?: () => LlmFace | undefined;
102
107
  readonly getAgentDefaultModel?: () => AgentDefaultModelFace | undefined;
108
+ /** The session's durable model selection (projection next); undefined when
109
+ * the session has never selected a model. */
110
+ readonly getSessionModel?: (sessionId: string) => SessionModelSelection | undefined;
103
111
  }
104
112
  /**
105
113
  * Pick the primary remote from `git remote -v` output and parse its fetch URL.
package/lib/host/core.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
3
3
  import { parseRemote } from "./remote.js";
4
+ import { snapshotFingerprint } from "./snapshot-fingerprint.js";
4
5
  import { isSafePath } from "./validate.js";
5
6
  import { SUGGEST_LANGUAGE_DEFAULT, SUGGEST_LANGUAGES, SUGGEST_LIMITS } from "./types.js";
6
7
  const DEFAULT_CONFIG = {
@@ -24,9 +25,14 @@ function normalizeConfig(raw) {
24
25
  const raw2 = unwrapVolatile(v);
25
26
  return typeof raw2 === "number" && Number.isFinite(raw2) ? Math.min(limit.max, Math.max(limit.min, Math.floor(raw2))) : limit.default;
26
27
  };
27
- const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
28
- const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
29
- const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
28
+ const provider = unwrapVolatile(c.suggestProvider);
29
+ const model = unwrapVolatile(c.suggestModel);
30
+ const effort = unwrapVolatile(c.suggestReasoningEffort);
31
+ const route = typeof provider === "string" && provider !== "" && typeof model === "string" && model !== "" ? {
32
+ suggestProvider: provider,
33
+ suggestModel: model,
34
+ ...typeof effort === "string" && effort !== "" ? { suggestReasoningEffort: effort } : {}
35
+ } : {};
30
36
  return {
31
37
  timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
32
38
  maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
@@ -80,6 +86,7 @@ function parsePrimaryRemote(stdout) {
80
86
  return parseRemote(name, fetchUrls.get(name));
81
87
  }
82
88
  const STATS_FANOUT_LIMIT = 200;
89
+ const UNTRACKED_NUMSTAT_LIMIT = 32;
83
90
  async function resolveBrowseRoot(deps, sessionId) {
84
91
  const ws = await resolveWorkspace(deps, sessionId);
85
92
  if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
@@ -186,7 +193,7 @@ async function snapshotForSession(deps, config, sessionId) {
186
193
  const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
187
194
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
188
195
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
189
- runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
196
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "status", deps.signal),
190
197
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
191
198
  runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
192
199
  runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
@@ -204,7 +211,20 @@ async function snapshotForSession(deps, config, sessionId) {
204
211
  allChanges = parseStatus(statusRes.run.stdout);
205
212
  statusLossy = statusRes.run.stdoutLossy;
206
213
  }
214
+ let untrackedCollapsed = false;
215
+ const untrackedEntries = allChanges.filter((c) => c.status === "untracked");
216
+ const collapseCandidate = untrackedEntries.some((c) => c.path.includes("/"));
217
+ if (statusLossy || untrackedEntries.length > config.maxChanges && collapseCandidate) {
218
+ const collapsed = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=normal"], root, "status-collapsed", deps.signal);
219
+ if ("run" in collapsed && !collapsed.run.timedOut && collapsed.run.exitCode === 0) {
220
+ const list = parseStatus(collapsed.run.stdout);
221
+ untrackedCollapsed = list.some((c) => c.status === "untracked" && c.isDirectory);
222
+ allChanges = list;
223
+ statusLossy = collapsed.run.stdoutLossy;
224
+ }
225
+ }
207
226
  const truncated = allChanges.length > config.maxChanges || statusLossy;
227
+ const truncatedReason = statusLossy ? "lossy" : allChanges.length > config.maxChanges ? "cap" : void 0;
208
228
  const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
209
229
  let staged = 0;
210
230
  let modified = 0;
@@ -230,9 +250,10 @@ async function snapshotForSession(deps, config, sessionId) {
230
250
  const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
231
251
  const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
232
252
  const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
253
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
254
+ const untrackedCountable = untrackedPaths.length <= UNTRACKED_NUMSTAT_LIMIT;
233
255
  let untrackedInsertions = 0;
234
- if (statsDetailed) {
235
- const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
256
+ if (untrackedCountable) {
236
257
  const safeUntracked = untrackedPaths.filter(isSafePath);
237
258
  if (safeUntracked.length > 0) {
238
259
  const perFile = await Promise.all(
@@ -252,7 +273,9 @@ async function snapshotForSession(deps, config, sessionId) {
252
273
  deletions: wt.deletions + stg.deletions,
253
274
  lastChangeAt,
254
275
  headCommittedAt: lastCommit?.dateIso ?? null,
255
- partial: !statsDetailed
276
+ // Collapsed untracked directories have no per-file line counts and count as
277
+ // one entry each, so the stats are an explicit lower bound there too.
278
+ partial: !statsDetailed || !untrackedCountable || untrackedCollapsed
256
279
  };
257
280
  const snapshot = {
258
281
  root,
@@ -271,6 +294,8 @@ async function snapshotForSession(deps, config, sessionId) {
271
294
  changes,
272
295
  stats,
273
296
  truncated,
297
+ ...truncatedReason !== void 0 ? { truncatedReason } : {},
298
+ ...untrackedCollapsed ? { untrackedCollapsed: true } : {},
274
299
  refreshIntervalMs: config.refreshIntervalMs,
275
300
  showInputPill: config.showInputPill,
276
301
  defaultDiffView: config.defaultDiffView,
@@ -278,7 +303,7 @@ async function snapshotForSession(deps, config, sessionId) {
278
303
  suggestEnabled: config.suggestEnabled,
279
304
  checkedAt: Date.now()
280
305
  };
281
- return { ok: true, value: snapshot };
306
+ return { ok: true, value: { ...snapshot, stateFingerprint: snapshotFingerprint(snapshot) } };
282
307
  }
283
308
  async function maxChangeMtime(deps, root, changes, cap = 200) {
284
309
  let max = null;
@@ -0,0 +1,2 @@
1
+ import type { SnapshotDeps } from './core.js';
2
+ export declare function createFsDeps(): SnapshotDeps['fs'];
@@ -0,0 +1,25 @@
1
+ import { lstat, readdir, readFile, readlink, realpath, rm, stat } from "node:fs/promises";
2
+ function createFsDeps() {
3
+ return {
4
+ realpath,
5
+ stat: async (p) => stat(p),
6
+ readFile,
7
+ symlinkTarget: async (p) => {
8
+ try {
9
+ return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ },
14
+ readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
15
+ // Untracked entries include whole directories (porcelain collapses `?? dir/`
16
+ // into one entry) and a plain `rm` fails EISDIR on them; the caller
17
+ // realpath-contains every target inside the repo root before removing.
18
+ remove: async (p) => {
19
+ await rm(p, { recursive: true, force: true });
20
+ }
21
+ };
22
+ }
23
+ export {
24
+ createFsDeps
25
+ };
@@ -0,0 +1,25 @@
1
+ import type { GitRunner } from './git.js';
2
+ import type { GitGraphNode } from './types.js';
3
+ /** Hard cap on the auxiliary walk: deep enough for a filtered page, bounded
4
+ * against a pathological DAG. */
5
+ export declare const HISTORY_TOPOLOGY_LIMIT = 10000;
6
+ /** Hard cap on the walk's stdout. The node cap alone does not bound a line: an
7
+ * octopus merge can list thousands of parents on one commit. Over budget the
8
+ * result is conservatively `[]` (an incomplete graph), never a partial parse. */
9
+ export declare const HISTORY_TOPOLOGY_MAX_BYTES: number;
10
+ /**
11
+ * Parse `git rev-list --parents` output: one `<hash> [<parent>…]` per line.
12
+ * Returns null whenever the capture cannot be trusted as a complete ancestry —
13
+ * a missing trailing newline (git always terminates each line, so a cut read can
14
+ * land exactly on a 40-char hash and otherwise read as a parentless root), any
15
+ * malformed line, or output past the node/byte budget. `[]` is a genuinely empty
16
+ * walk, never a degraded one.
17
+ */
18
+ export declare function parseGraphNodes(stdout: string): GitGraphNode[] | null;
19
+ /**
20
+ * Walk the page's raw ancestry. Seeds must already be full hashes; anything
21
+ * else is dropped rather than passed as argv. Resolves `[]` on every failure
22
+ * path (spawn throw, cancel, timeout, non-zero exit, lossy output, malformed
23
+ * output) so a filtered history page is never lost to this auxiliary command.
24
+ */
25
+ export declare function walkHistoryTopology(runner: GitRunner, root: string, seeds: readonly string[], signal?: AbortSignal): Promise<GitGraphNode[]>;
@@ -0,0 +1,55 @@
1
+ import { runCommand } from "./core.js";
2
+ const HISTORY_TOPOLOGY_LIMIT = 1e4;
3
+ const HISTORY_TOPOLOGY_MAX_BYTES = 2 * 1024 * 1024;
4
+ const FULL_HASH = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;
5
+ function parseGraphNodes(stdout) {
6
+ if (stdout === "") return [];
7
+ if (Buffer.byteLength(stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return null;
8
+ if (!stdout.endsWith("\n")) return null;
9
+ const lines = [];
10
+ for (const raw of stdout.split("\n")) {
11
+ const line = raw.trim();
12
+ if (line !== "") lines.push(line);
13
+ }
14
+ if (lines.length > HISTORY_TOPOLOGY_LIMIT) return null;
15
+ const nodes = [];
16
+ const seen = /* @__PURE__ */ new Set();
17
+ for (const line of lines) {
18
+ const fields = line.split(" ");
19
+ const hash = fields[0];
20
+ if (hash === void 0 || !FULL_HASH.test(hash)) return null;
21
+ const parents = [];
22
+ for (const parent of fields.slice(1)) {
23
+ if (!FULL_HASH.test(parent)) return null;
24
+ parents.push(parent);
25
+ }
26
+ if (seen.has(hash)) continue;
27
+ seen.add(hash);
28
+ nodes.push({ hash, parents });
29
+ }
30
+ return nodes;
31
+ }
32
+ async function walkHistoryTopology(runner, root, seeds, signal) {
33
+ const safeSeeds = seeds.filter((seed) => FULL_HASH.test(seed));
34
+ if (safeSeeds.length === 0) return [];
35
+ const argv = [
36
+ "git",
37
+ "rev-list",
38
+ "--parents",
39
+ `--max-count=${HISTORY_TOPOLOGY_LIMIT}`,
40
+ "--end-of-options",
41
+ ...safeSeeds
42
+ ];
43
+ const res = await runCommand(runner, argv, root, "history-topology", signal);
44
+ if (!("run" in res)) return [];
45
+ const { run } = res;
46
+ if (run.cancelled || run.timedOut || run.exitCode !== 0 || run.stdoutLossy) return [];
47
+ if (Buffer.byteLength(run.stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return [];
48
+ return parseGraphNodes(run.stdout) ?? [];
49
+ }
50
+ export {
51
+ HISTORY_TOPOLOGY_LIMIT,
52
+ HISTORY_TOPOLOGY_MAX_BYTES,
53
+ parseGraphNodes,
54
+ walkHistoryTopology
55
+ };
@@ -10,14 +10,14 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
11
  import Schema from '@deepseek-ai/schemastery';
12
12
  import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.js';
13
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, SuggestLanguage, ResolvedLanguage, } from './types.js';
13
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, SuggestLanguage, ResolvedLanguage, ModelCatalogGroup, ModelCatalogModel, ModelCatalogEffort, ModelCatalogFailure, } from './types.js';
14
14
  export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote, readSuggestLanguage } from './core.js';
15
15
  export { createGitRunner } from './git.js';
16
16
  export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.js';
17
17
  export { parseRemote, classifyHost } from './remote.js';
18
18
  export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.js';
19
19
  export { runQuery } from './queries.js';
20
- export { runSuggest, resolveSuggestLanguage, parseStyleHistory, isCjkText } from './suggest.js';
20
+ export { runSuggest, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from './suggest.js';
21
21
  export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.js';
22
22
  export declare class GitPanelService extends TypertRemoteService {
23
23
  static inject: string[];
@@ -36,6 +36,9 @@ export declare class GitPanelService extends TypertRemoteService {
36
36
  suggestMaxBytes: Schema<number, number, "volatile-defined">;
37
37
  suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
38
38
  suggestTimeoutMs: Schema<number, number, "volatile-defined">;
39
+ suggestProvider: Schema<string, string, "volatile">;
40
+ suggestModel: Schema<string, string, "volatile">;
41
+ suggestReasoningEffort: Schema<string, string, "volatile">;
39
42
  }>>, Schemastery.ObjectT<NoInfer<{
40
43
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
41
44
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
@@ -45,6 +48,9 @@ export declare class GitPanelService extends TypertRemoteService {
45
48
  suggestMaxBytes: Schema<number, number, "volatile-defined">;
46
49
  suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
47
50
  suggestTimeoutMs: Schema<number, number, "volatile-defined">;
51
+ suggestProvider: Schema<string, string, "volatile">;
52
+ suggestModel: Schema<string, string, "volatile">;
53
+ suggestReasoningEffort: Schema<string, string, "volatile">;
48
54
  }>>, "plain">;
49
55
  private readonly deps;
50
56
  private config;
package/lib/host/index.js CHANGED
@@ -49,8 +49,8 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
49
49
  var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
50
50
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
51
51
  import Schema from "@deepseek-ai/schemastery";
52
- import { lstat, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
53
52
  import { createGitRunner } from "./git.js";
53
+ import { createFsDeps } from "./fs-deps.js";
54
54
  import { normalizeConfig, snapshotForSession, DEFAULT_CONFIG } from "./core.js";
55
55
  import { runAction } from "./actions.js";
56
56
  import { runQuery } from "./queries.js";
@@ -62,7 +62,7 @@ import { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashL
62
62
  import { parseRemote, classifyHost } from "./remote.js";
63
63
  import { isSafePath, planAction, runAction as runAction2, classifyActionFailure } from "./actions.js";
64
64
  import { runQuery as runQuery2 } from "./queries.js";
65
- import { runSuggest as runSuggest2, resolveSuggestLanguage, parseStyleHistory, isCjkText } from "./suggest.js";
65
+ import { runSuggest as runSuggest2, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from "./suggest.js";
66
66
  import { readVersionInfo as readVersionInfo2, checkLatestVersion as checkLatestVersion2, compareVersions, parseRepository } from "./version.js";
67
67
  class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _suggest_dec = [Remote("suggest")], _version_dec = [Remote("version")], _a) {
68
68
  constructor(ctx, config) {
@@ -79,28 +79,14 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
79
79
  const rootCache = /* @__PURE__ */ new Map();
80
80
  const rootNegCache = /* @__PURE__ */ new Map();
81
81
  const get = (key) => ctx.get(key);
82
- const fs = {
83
- realpath,
84
- stat: async (p) => stat(p),
85
- readFile,
86
- symlinkTarget: async (p) => {
87
- try {
88
- return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
89
- } catch {
90
- return null;
91
- }
92
- },
93
- readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
94
- remove: async (p) => {
95
- await rm(p, { force: true });
96
- }
97
- };
82
+ const fs = createFsDeps();
98
83
  const subprocess = get("subprocess");
99
84
  const run = subprocess === void 0 ? { run: async () => {
100
85
  throw new Error("subprocess service unavailable");
101
86
  } } : createGitRunner(subprocess, config.timeoutMs, config.maxBytes);
102
87
  const sessions = get("sessions");
103
88
  const persistence = get("sessionPersistence");
89
+ const projections = get("sessionProjections");
104
90
  return {
105
91
  run,
106
92
  fs,
@@ -123,7 +109,25 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
123
109
  // a model backend, where the suggest endpoint reports `llm-unavailable`
124
110
  // rather than failing activation (kept out of `static inject` on purpose).
125
111
  getLlm: () => get("llm"),
126
- getAgentDefaultModel: () => get("agentDefaultModel")
112
+ getAgentDefaultModel: () => get("agentDefaultModel"),
113
+ // Session model selection: projection `next` is pending ?? lastUsed —
114
+ // the route the next prompt in this session would use.
115
+ getSessionModel: (sessionId) => {
116
+ const session = sessions?.get(sessionId);
117
+ if (session === void 0 || projections === void 0) return void 0;
118
+ try {
119
+ const next = projections.snapshot(session, ["modelSelection"]).values?.modelSelection?.next;
120
+ if (next == null || typeof next.provider !== "string" || typeof next.model !== "string") return void 0;
121
+ if (next.provider === "" || next.model === "") return void 0;
122
+ return {
123
+ provider: next.provider,
124
+ model: next.model,
125
+ ...typeof next.reasoningEffort === "string" && next.reasoningEffort !== "" ? { reasoningEffort: next.reasoningEffort } : {}
126
+ };
127
+ } catch {
128
+ return void 0;
129
+ }
130
+ }
127
131
  };
128
132
  }
129
133
  async snapshot(request, signal) {
@@ -189,15 +193,23 @@ __publicField(GitPanelService, "Config", Schema.object({
189
193
  // a literal here could silently drift from what normalizeConfig enforces.
190
194
  suggestMaxBytes: Schema.number().default(DEFAULT_CONFIG.suggestMaxBytes).volatile().description("AI \u751F\u6210\u8F93\u5165\u9884\u7B97\uFF08diff \u5B57\u8282\u4E0A\u9650\uFF0C16\u2013512 KiB\uFF09"),
191
195
  suggestMaxOutputTokens: Schema.number().default(DEFAULT_CONFIG.suggestMaxOutputTokens).volatile().description("AI \u751F\u6210\u8F93\u51FA\u4E0A\u9650\uFF08tokens\uFF0C256\u201316384\uFF09"),
192
- suggestTimeoutMs: Schema.number().default(DEFAULT_CONFIG.suggestTimeoutMs).volatile().description("AI \u751F\u6210\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF0C5\u2013120 \u79D2\uFF09")
196
+ suggestTimeoutMs: Schema.number().default(DEFAULT_CONFIG.suggestTimeoutMs).volatile().description("AI \u751F\u6210\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF0C5\u2013120 \u79D2\uFF09"),
197
+ // Fixed model route: both halves of the pair must be non-empty to take
198
+ // effect (normalizeConfig); effort is omitted from the request when blank.
199
+ // No .default(): without one the field stays optional in the object schema.
200
+ suggestProvider: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684 provider\uFF08\u7559\u7A7A = \u8DDF\u968F\u4F1A\u8BDD\u9ED8\u8BA4\uFF09"),
201
+ suggestModel: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684 model id\uFF08\u987B\u4E0E provider \u6210\u5BF9\uFF09"),
202
+ suggestReasoningEffort: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684\u63A8\u7406\u5F3A\u5EA6\uFF08\u7559\u7A7A = \u4E0D\u8BBE\u7F6E\uFF0C\u7528\u6A21\u578B\u9ED8\u8BA4\uFF09")
193
203
  }));
194
204
  var index_default = GitPanelService;
195
205
  export {
196
206
  DEFAULT_CONFIG2 as DEFAULT_CONFIG,
197
207
  GitPanelService,
208
+ LONG_LINE_MAX_CHARS,
198
209
  checkLatestVersion2 as checkLatestVersion,
199
210
  classifyActionFailure,
200
211
  classifyHost,
212
+ compactLongLines,
201
213
  compareVersions,
202
214
  createGitRunner2 as createGitRunner,
203
215
  index_default as default,
@@ -16,7 +16,22 @@ export interface LlmFace {
16
16
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
17
17
  listProviders?(): readonly {
18
18
  readonly id: string;
19
+ readonly name?: string;
19
20
  }[];
21
+ /** Adapter-advertised models for one provider; optional on the wire. */
22
+ listModels?(provider: string): Promise<readonly {
23
+ readonly id: string;
24
+ readonly name?: string;
25
+ }[]>;
26
+ /** Exact-route metadata (reasoning efforts etc.); optional on the wire. */
27
+ resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{
28
+ readonly reasoning?: {
29
+ readonly efforts: readonly {
30
+ readonly id: string;
31
+ readonly name: string;
32
+ }[];
33
+ };
34
+ }>;
20
35
  }
21
36
  /** Minimal face of the host `agentDefaultModel` service. */
22
37
  export interface AgentDefaultModelFace {
@@ -25,3 +40,9 @@ export interface AgentDefaultModelFace {
25
40
  readonly model: string;
26
41
  } | undefined;
27
42
  }
43
+ /** One session's durable model selection (projection `next`: pending, else lastUsed). */
44
+ export interface SessionModelSelection {
45
+ readonly provider: string;
46
+ readonly model: string;
47
+ readonly reasoningEffort?: string;
48
+ }