@xia-sc/dsh-git 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -122,8 +122,13 @@ The commit area follows the real order of operations: **stage all → draft →
122
122
 
123
123
  The model route comes from the current session's `modelSelection` projection
124
124
  (pending pick first, then last used), falling back to the host's first
125
- registered route. Failures are reported in the last-operation output with a
126
- localized sentence (no changes / no model configured / draft failed, …).
125
+ registered route. The request carries the **current session id** (`sessionId`):
126
+ some gateways (the opencode-style routes on this machine, for one) require a
127
+ session-affinity header, and the host forwards a session id to its adapter only
128
+ when the request has one — without it the gateway answers `MissingSessionID`.
129
+ Failures are reported in the last-operation output with a localized sentence
130
+ (no changes / no model configured / draft failed, …); an unrecognized failure
131
+ code leads with the localized sentence and appends the host's own diagnosis.
127
132
 
128
133
  ## Architecture
129
134
 
@@ -232,7 +237,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
232
237
  | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
233
238
  | `push` | `{ cwd }` | `{ message }` (120s timeout) |
234
239
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
235
- | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. Failure code in `error.details.code`: `no-changes`, `no-provider`, `no-model`, `llm-truncated` (the output cap ran out before any text was written), `llm-empty`, `cancelled`, `llm-failed`. |
240
+ | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. `sessionId` is an optional non-empty string (over 200 chars is `invalid-session`); the panel sends the current session id so session-affine gateways can route the call. Failure code in `error.details.code`: `no-changes`, `no-provider`, `no-model`, `llm-truncated` (the output cap ran out before any text was written), `llm-empty`, `cancelled`, `llm-failed`. |
236
241
 
237
242
  > A failed result carries `error.code === "internal"` on the wire (the Connection
238
243
  > envelope only requires a string), with the plugin's own diagnostic in
package/README.md CHANGED
@@ -85,8 +85,11 @@ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠
85
85
  模型,生成的提交信息直接填入输入框;不满意可改,也可以直接手写。
86
86
 
87
87
  生成用的模型路由取当前会话的 `modelSelection` 投影(待生效的选择优先,
88
- 其次是上次实际使用),取不到时回落到宿主注册的第一条路由。失败会以
89
- 本地化文案显示在"上次操作输出"里(无可用改动 / 未配置模型 / 生成失败等)。
88
+ 其次是上次实际使用),取不到时回落到宿主注册的第一条路由。请求会带上**当前会话 id**
89
+ (`sessionId`),因为部分网关(例如本机配置的 opencode 系路由)要求会话亲和头,
90
+ 而宿主只在请求带 `sessionId` 时才把它转给适配器——不带就会被网关以
91
+ `MissingSessionID` 拒掉。失败会以本地化文案显示在"上次操作输出"里
92
+ (无可用改动 / 未配置模型 / 生成失败等;未知失败码先给本地化短句,再附宿主的原始诊断)。
90
93
 
91
94
  ## 架构
92
95
 
@@ -180,7 +183,7 @@ dsh plugin --profile web remove @xia-sc/dsh-git
180
183
  | `commit` | `{ cwd, message }` | `{ message }`;未配置 `user.name/email` 时报 `missing-author` 错误 |
181
184
  | `push` | `{ cwd }` | `{ message }`(120s 超时) |
182
185
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }`(钳制 1..50) |
183
- | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;失败码见 `error.details.code`:`no-changes`、`no-provider`、`no-model`、`llm-truncated`(输出上限用尽、一个字都没写出来)、`llm-empty`、`cancelled`、`llm-failed`。 |
186
+ | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;`sessionId` 为可选的非空字符串(超过 200 字符报 `invalid-session`),面板会带上当前会话 id 供需要会话亲和的网关路由。失败码见 `error.details.code`:`no-changes`、`no-provider`、`no-model`、`llm-truncated`(输出上限用尽、一个字都没写出来)、`llm-empty`、`cancelled`、`llm-failed`。 |
184
187
 
185
188
  > 失败结果的 `error.code` 在线路上固定为 `"internal"`(Connection 信封只要求它是字符串),
186
189
  > 插件自己的诊断码放在 `error.details.code`;客户端按该码做本地化文案。
package/lib/client.js CHANGED
@@ -76,6 +76,8 @@ window.__ModuleLoader__.load({
76
76
  "generate.truncated": "模型还没写出提交信息就用完了 token 上限(思考模型会先花掉这份预算),再点一次通常即可",
77
77
  "generate.cancelled": "生成已取消",
78
78
  "generate.failed": "生成失败",
79
+ "generate.failedDetail": "生成失败:{message}",
80
+ "generate.failedRoute": "生成失败:{message}(模型路由 {provider} / {model})",
79
81
  "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
80
82
  "commit.empty": "请输入提交信息",
81
83
  "changes.title": "变更",
@@ -180,6 +182,8 @@ window.__ModuleLoader__.load({
180
182
  "generate.truncated": "The model hit its token cap before writing anything (a reasoning model spends that budget thinking first) — trying again usually works",
181
183
  "generate.cancelled": "Draft cancelled",
182
184
  "generate.failed": "Draft failed",
185
+ "generate.failedDetail": "Draft failed: {message}",
186
+ "generate.failedRoute": "Draft failed: {message} (route {provider} / {model})",
183
187
  "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
184
188
  "commit.empty": "Enter a commit message",
185
189
  "changes.title": "Changes",
@@ -251,7 +255,12 @@ window.__ModuleLoader__.load({
251
255
  panel: {
252
256
  position: "fixed",
253
257
  // Sit above the composer card so the expanded panel never covers
254
- // the input box (the input.left pill sits in the tool row).
258
+ // the input box. 168px is a measurement of the composer stack
259
+ // (card + tool row + the dock band), not a published contract:
260
+ // ui-conversation keeps that geometry in its own module CSS
261
+ // (`--dsh-composer-stack-gap` etc.). If the composer grows or
262
+ // shrinks, re-measure here and in DIFF_PANEL_HEIGHT below — the
263
+ // panel is anchored by `bottom`, so both move together.
255
264
  bottom: 168,
256
265
  left: "50%",
257
266
  transform: "translateX(-50%)",
@@ -878,6 +887,33 @@ window.__ModuleLoader__.load({
878
887
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
879
888
  }
880
889
 
890
+ /**
891
+ * The model route one session's AI draft should use, or null to let the
892
+ * host fall back to its first registered provider route.
893
+ *
894
+ * Two host generations carry the same `modelSelection` projection: the
895
+ * row's own `projectionValues` (present since 0.1.6) and, since 0.1.7,
896
+ * the snapshot's per-session `projectionsBySession[id].values`. Read the
897
+ * row first, then the shared record, so neither generation loses a route
898
+ * the user already chose. `next` (a pending pick) beats `lastUsed`.
899
+ *
900
+ * @param summary - `byId[sessionId]` row, when the snapshot has one.
901
+ * @param sessions - the whole `useSessions` snapshot (may be undefined).
902
+ * @param sessionId - the bound session id, or null/undefined.
903
+ * @returns `{ provider, model }`, or null.
904
+ */
905
+ function sessionModelRoute(summary, sessions, sessionId) {
906
+ var selection = summary && summary.projectionValues ? summary.projectionValues.modelSelection : undefined;
907
+ if (!selection && sessionId !== null && sessionId !== undefined && sessions && sessions.projectionsBySession) {
908
+ var record = sessions.projectionsBySession[sessionId];
909
+ selection = record && record.values ? record.values.modelSelection : undefined;
910
+ }
911
+ if (!selection) return null;
912
+ var effective = selection.next || selection.lastUsed;
913
+ if (!effective || !effective.provider || !effective.model) return null;
914
+ return { provider: effective.provider, model: effective.model };
915
+ }
916
+
881
917
  /**
882
918
  * Strip ANSI/VT escape sequences from git's human-readable output.
883
919
  *
@@ -1100,8 +1136,11 @@ window.__ModuleLoader__.load({
1100
1136
  commit: function (cwd, message) { return rpc(ctx, "commit", { cwd: cwd, message: message }); },
1101
1137
  push: function (cwd) { return rpc(ctx, "push", { cwd: cwd }); },
1102
1138
  log: function (cwd) { return rpc(ctx, "log", { cwd: cwd, count: 10 }); },
1103
- generateMessage: function (cwd, mode, provider, model) {
1104
- return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model });
1139
+ generateMessage: function (cwd, mode, provider, model, sessionId) {
1140
+ // The session travels with the request: session-labelled
1141
+ // providers reject an unidentified call, and the host forwards
1142
+ // a session id to its adapter only when one is present.
1143
+ return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model, sessionId: sessionId });
1105
1144
  }
1106
1145
  };
1107
1146
 
@@ -1800,10 +1839,14 @@ window.__ModuleLoader__.load({
1800
1839
  // with "selector is not a function".
1801
1840
  var sessions = useSessions(function (s) { return s; });
1802
1841
 
1803
- // NOTE: the shell's seed react-dom is an experimental 18.3.1-next
1804
- // build where React.useSyncExternalStore misbehaves (returns
1805
- // undefined / throws), so subscribe through the classic
1806
- // useState + useEffect pattern instead safe on any React.
1842
+ // NOTE: subscribe through the classic useState + useEffect pattern
1843
+ // instead of React.useSyncExternalStore, so this store's own
1844
+ // subscription is independent of whichever implementation the seed
1845
+ // ships. This is about keeping one dependency out of the path, not a
1846
+ // claim that the seeded build is broken: the framework's own
1847
+ // `useSessions` prop already goes through the renderer's
1848
+ // useSyncExternalStoreWithSelector, so an unusable seed would break
1849
+ // the session list before it broke this panel.
1807
1850
  var stateRef = React.useState(function () { return store.getSnapshot(); });
1808
1851
  var state = stateRef[0];
1809
1852
  var setState = stateRef[1];
@@ -1824,16 +1867,10 @@ window.__ModuleLoader__.load({
1824
1867
  var sessionId = state.sessionId;
1825
1868
  var currentSummary = sessionId !== null && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
1826
1869
 
1827
- // The session's own model route, when one is recorded: the durable
1828
- // `modelSelection` projection, pending pick first, last used second.
1829
- // Generation falls back to the host's first registered route.
1830
- var sessionRoute = (function () {
1831
- var selection = currentSummary && currentSummary.projectionValues ? currentSummary.projectionValues.modelSelection : undefined;
1832
- if (!selection) return null;
1833
- var effective = selection.next || selection.lastUsed;
1834
- if (!effective || !effective.provider || !effective.model) return null;
1835
- return { provider: effective.provider, model: effective.model };
1836
- })();
1870
+ // The session's own model route, when one is recorded. Both projection
1871
+ // surfaces it can come from are handled by sessionModelRoute; a
1872
+ // missing route leaves generation to the host's first registered one.
1873
+ var sessionRoute = sessionModelRoute(currentSummary, sessions, sessionId);
1837
1874
 
1838
1875
  var messageRef = React.useState("");
1839
1876
  var message = messageRef[0];
@@ -2449,7 +2486,26 @@ window.__ModuleLoader__.load({
2449
2486
  };
2450
2487
  var text = known[details.code];
2451
2488
  if (text === undefined) {
2452
- text = res && res.error && res.error.message ? String(res.error.message) : t("generate.failed");
2489
+ // Unknown code (a transport failure, a provider rejection,
2490
+ // …): lead with the localized sentence, keep the host's
2491
+ // own words as the diagnosis, and name the route that
2492
+ // failed — "which provider is broken" is the actionable
2493
+ // part when a gateway refuses the call.
2494
+ var detail = res && res.error && res.error.message ? String(res.error.message) : "";
2495
+ var failedRoute = details.provider && details.model
2496
+ ? { provider: details.provider, model: details.model }
2497
+ : null;
2498
+ if (detail === "") {
2499
+ text = t("generate.failed");
2500
+ } else if (failedRoute !== null) {
2501
+ text = t("generate.failedRoute", {
2502
+ message: shortText(detail, 160),
2503
+ provider: failedRoute.provider,
2504
+ model: failedRoute.model
2505
+ });
2506
+ } else {
2507
+ text = t("generate.failedDetail", { message: shortText(detail, 160) });
2508
+ }
2453
2509
  }
2454
2510
  return { kind: "error", text: text };
2455
2511
  }
@@ -2462,7 +2518,8 @@ window.__ModuleLoader__.load({
2462
2518
  state.cwd,
2463
2519
  generateMode,
2464
2520
  route ? route.provider : undefined,
2465
- route ? route.model : undefined
2521
+ route ? route.model : undefined,
2522
+ state.sessionId === null ? undefined : state.sessionId
2466
2523
  );
2467
2524
  }, describeGenerate).then(function (res) {
2468
2525
  if (res && res.ok === true && res.value && res.value.message) {
@@ -2641,7 +2698,8 @@ window.__ModuleLoader__.load({
2641
2698
  ? true
2642
2699
  : (summary.retainedBy.mainView || 0) > 0;
2643
2700
 
2644
- // Classic subscription pattern (see GitFloatingPanel for why).
2701
+ // Classic subscription pattern (see GitFloatingPanel's note: keep this
2702
+ // store independent of the seed's useSyncExternalStore build).
2645
2703
  var stateRef = React.useState(function () { return store.getSnapshot(); });
2646
2704
  var state = stateRef[0];
2647
2705
  var setState = stateRef[1];
@@ -2677,17 +2735,22 @@ window.__ModuleLoader__.load({
2677
2735
  }
2678
2736
 
2679
2737
  // Left-aligned compact pill in the row band ABOVE the composer card
2680
- // (conversation.input.dock). The padding-left mirrors the centered
2681
- // card's left margin, so the pill hugs the textarea's top-left
2682
- // corner instead of floating at the conversation column's edge.
2738
+ // (conversation.input.dock). The row box is capped at the composer
2739
+ // card's own width and centered — the same way ui-conversation's own
2740
+ // dock occupant (QueueDock) aligns itself — so the pill hugs the
2741
+ // textarea's top-left corner instead of floating at the conversation
2742
+ // column's edge. `--dsh-composer-card-max-width` is ui-conversation's
2743
+ // internal variable (no published contract, and 0.1.7 added a second
2744
+ // value for the embedded body); the 778px fallback keeps this row sane
2745
+ // if it is ever renamed.
2683
2746
  return h("div", {
2684
2747
  style: {
2685
2748
  boxSizing: "border-box",
2686
2749
  display: "flex",
2687
2750
  justifyContent: "flex-start",
2688
2751
  width: "100%",
2689
- paddingLeft: "calc((100% - var(--dsh-composer-card-max-width, 778px)) / 2)",
2690
- margin: "2px 0"
2752
+ maxWidth: "var(--dsh-composer-card-max-width, 778px)",
2753
+ margin: "2px auto"
2691
2754
  },
2692
2755
  "data-dsh-git": "dock-row"
2693
2756
  }, h("button", {
@@ -2772,7 +2835,8 @@ window.__ModuleLoader__.load({
2772
2835
  sectionView: sectionView,
2773
2836
  changeTagStyle: changeTagStyle,
2774
2837
  panelBoxStyle: panelBoxStyle,
2775
- DIFF_PANEL_HEIGHT: DIFF_PANEL_HEIGHT
2838
+ DIFF_PANEL_HEIGHT: DIFF_PANEL_HEIGHT,
2839
+ sessionModelRoute: sessionModelRoute
2776
2840
  };
2777
2841
  return module.exports;
2778
2842
  }
package/lib/index.js CHANGED
@@ -49,7 +49,6 @@
49
49
  * `/api` transport — loopback or a configured trusted authority, same-origin.
50
50
  */
51
51
  import { execFile } from "node:child_process";
52
- import { randomUUID } from "node:crypto";
53
52
  import { isAbsolute } from "node:path";
54
53
 
55
54
  /** Stable Cordis plugin name. */
@@ -95,6 +94,12 @@ const LLM_STAT_MAX_CHARS = 2000;
95
94
  const LLM_MAX_TOKENS = 8192;
96
95
  /** Accepted `generateMessage` modes. */
97
96
  const GENERATE_MODES = ["staged", "unstaged", "all"];
97
+ /**
98
+ * Longest session id this plugin will forward to the LLM service. Real ids are
99
+ * short branded strings; the cap only keeps a hostile caller from stuffing
100
+ * arbitrary text into a request header.
101
+ */
102
+ const SESSION_ID_MAX_CHARS = 200;
98
103
  /** Default mode: the only one whose content is what `commit` will actually record. */
99
104
  const GENERATE_MODE_DEFAULT = "staged";
100
105
 
@@ -682,8 +687,10 @@ function endpointFromPath(pathname) {
682
687
 
683
688
  /**
684
689
  * Read a request body with a hard byte cap.
685
- * Uses the classic data/end/error events rather than async iteration: the
686
- * IncomingMessage async iterator is not a stable path in this runtime.
690
+ * Deliberately uses the classic data/end/error events rather than async
691
+ * iteration: the host's own bridge does `for await (const chunk of req)`, and
692
+ * keeping this reader on the event API means one dependency fewer on an
693
+ * IncomingMessage behaviour this plugin cannot pin.
687
694
  * @param req - node:http request.
688
695
  * @param maxBytes - cap; exceeding it resolves to undefined.
689
696
  * @returns the utf8 body, or undefined on overrun/error/abort.
@@ -1038,17 +1045,21 @@ async function resolveLlmRoute(ctx, rawProvider, rawModel) {
1038
1045
  * Hand-built rather than imported from `@deepseek-ai/dsh-llm`: this package
1039
1046
  * deliberately declares no `@deepseek-ai/*` runtime imports, because a plugin
1040
1047
  * installed with pnpm `link:` resolves them from its own real source path,
1041
- * where no host tree exists. The shape must stay exactly this: a fresh id, the
1042
- * `user` role, one text block, and a plugin source.
1048
+ * where no host tree exists.
1049
+ *
1050
+ * The shape is the host's documented hand-built one-shot input
1051
+ * (`RequestUserInput`): the `user` role plus one text block, and deliberately
1052
+ * **no `id` and no `source`**. dsh >= 0.1.7 retired the catch-all `plugin`
1053
+ * source kind — `MessageSourceMap` has no such member ("each producer declares
1054
+ * its own `kind` in its own module; there is no shared catch-all `plugin`
1055
+ * kind", `dsh-llm/lib/types/message.d.ts`), and Session format v4 refuses
1056
+ * `{kind:"plugin"}` outright (`dsh-session-format-v3-to-v4`). A one-shot
1057
+ * request needs no durable identity: only `provider`/`model`/`messages`/… are
1058
+ * read off {@link GenerateOptions}.
1043
1059
  * @param text - model-facing prompt body.
1044
1060
  */
1045
1061
  function generationMessage(text) {
1046
- return Object.freeze({
1047
- id: randomUUID(),
1048
- role: "user",
1049
- content: Object.freeze([Object.freeze({ type: "text", text })]),
1050
- source: Object.freeze({ kind: "plugin", plugin: name })
1051
- });
1062
+ return { role: "user", content: [{ type: "text", text }] };
1052
1063
  }
1053
1064
 
1054
1065
  /**
@@ -1057,18 +1068,27 @@ function generationMessage(text) {
1057
1068
  * @param prompt - `{ system, body }` from {@link generationPrompt}.
1058
1069
  * @param route - `{ provider, model }` from {@link resolveLlmRoute}.
1059
1070
  * @param signal - cancellation (browser abort plus the server ceiling).
1071
+ * @param sessionId - the caller's session, when it has one. Forwarded as
1072
+ * `GenerateOptions.sessionId`; adapters that need session affinity (they
1073
+ * receive it only when it is present — see `dsh-llm-pi-ai`'s options mapping)
1074
+ * reject an unidentified request outright, so an AI draft from the panel must
1075
+ * carry one.
1060
1076
  * @returns the trimmed message.
1061
1077
  * @throws on a terminal stream failure, an output cap hit before any text
1062
1078
  * existed (`llm-truncated`), or genuinely empty output (`llm-empty`).
1063
1079
  */
1064
- async function requestCommitMessage(ctx, prompt, route, signal) {
1080
+ async function requestCommitMessage(ctx, prompt, route, signal, sessionId = undefined) {
1065
1081
  const options = {
1066
1082
  provider: route.provider,
1067
1083
  model: route.model,
1068
1084
  messages: [generationMessage(prompt.body)],
1069
1085
  system: prompt.system,
1070
1086
  maxTokens: LLM_MAX_TOKENS,
1071
- signal
1087
+ signal,
1088
+ // Session-labelled routes (an "opencode go"-style gateway is the local
1089
+ // example) answer 400 when the request carries no session identity, and
1090
+ // the host's pi-ai adapter forwards this field only when it is present.
1091
+ ...(sessionId === undefined ? {} : { sessionId })
1072
1092
  };
1073
1093
  // Final text comes from the assembled blocks; the delta map is a fallback for
1074
1094
  // an adapter that emits text without a closing `block-end`.
@@ -1108,13 +1128,20 @@ async function requestCommitMessage(ctx, prompt, route, signal) {
1108
1128
  * @param rawMode - `staged`, `unstaged`, or `all`.
1109
1129
  * @param rawProvider - caller's provider route (optional).
1110
1130
  * @param rawModel - caller's model id (optional).
1131
+ * @param rawSessionId - caller's session id (optional; see
1132
+ * {@link requestCommitMessage} for why a session-scoped caller must send it).
1111
1133
  * @param signal - transport cancellation.
1112
1134
  */
1113
- async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, signal) {
1135
+ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, rawSessionId, signal) {
1114
1136
  if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
1115
1137
  if (rawMode !== undefined && rawMode !== null && !GENERATE_MODES.includes(rawMode)) {
1116
1138
  return fail("invalid-mode", `mode must be one of ${GENERATE_MODES.join(", ")}`);
1117
1139
  }
1140
+ if (rawSessionId !== undefined && rawSessionId !== null
1141
+ && (typeof rawSessionId !== "string" || rawSessionId === "" || rawSessionId.length > SESSION_ID_MAX_CHARS)) {
1142
+ return fail("invalid-session", "sessionId must be a non-empty string when present");
1143
+ }
1144
+ const sessionId = typeof rawSessionId === "string" ? rawSessionId : undefined;
1118
1145
  const mode = rawMode ?? GENERATE_MODE_DEFAULT;
1119
1146
  const changes = await readChangesForMode(rawCwd, mode, signal);
1120
1147
  if (changes.ok !== true) return changes;
@@ -1138,10 +1165,16 @@ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, s
1138
1165
  const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
1139
1166
  if (combined.aborted) return fail("cancelled", "generation was cancelled", { mode });
1140
1167
  try {
1141
- const message = await requestCommitMessage(ctx, prompt, route, combined);
1168
+ const message = await requestCommitMessage(ctx, prompt, route, combined, sessionId);
1142
1169
  return ok({ message, mode, provider: route.provider, model: route.model });
1143
1170
  } catch (error) {
1144
- return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), { mode });
1171
+ // The route rides along so the panel can name the provider that failed:
1172
+ // a route-level gateway rejection otherwise reads as a plugin bug.
1173
+ return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), {
1174
+ mode,
1175
+ provider: route.provider,
1176
+ model: route.model
1177
+ });
1145
1178
  }
1146
1179
  }
1147
1180
 
@@ -1187,7 +1220,7 @@ async function dispatch(ctx, endpoint, payload, signal) {
1187
1220
  case "log":
1188
1221
  return gitLog(args.cwd, args.count, signal);
1189
1222
  case "generateMessage":
1190
- return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, signal);
1223
+ return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, args.sessionId, signal);
1191
1224
  default:
1192
1225
  return fail("unknown-endpoint", `unknown git endpoint ${JSON.stringify(endpoint)}`);
1193
1226
  }
@@ -1203,8 +1236,10 @@ function apply(ctx) {
1203
1236
  // The channel runs git against caller-supplied absolute paths, so it must
1204
1237
  // never mount unfenced: the connection service's Host/Origin + browser-cookie
1205
1238
  // check is the only gate. `requestRejection` is the >= 0.1.5-rc.1 form of that
1206
- // check; an older host without it fails loudly instead of serving an open
1207
- // channel.
1239
+ // check. An older host without it still gets no channel (this throws), but
1240
+ // note the throw is *stderr-only* for the operator: dsh-app-boot treats
1241
+ // non-required rows as optional, so this row is skipped with a warning rather
1242
+ // than failing the boot. Never downgrade the guard to a pass-through.
1208
1243
  const connection = ctx.get("connection");
1209
1244
  if (connection === undefined || typeof connection.requestRejection !== "function") {
1210
1245
  throw new Error(`${name}: this plugin requires dsh >= 0.1.5-rc.1 (connection.requestRejection is unavailable, so /dsh-git-rpc could not be fenced)`);
@@ -1213,7 +1248,15 @@ function apply(ctx) {
1213
1248
  kind: "prefix",
1214
1249
  path: RPC_CHANNEL,
1215
1250
  handler: async (req, res) => {
1216
- const pathname = String(req.url ?? "/").split("?")[0];
1251
+ // The host's own router matches on `new URL(req.url, "http://x").pathname`,
1252
+ // so an absolute-form request target (a proxy in front of dsh web) must
1253
+ // resolve here too. Origin-form targets are kept verbatim: `endpointFromPath`
1254
+ // is the stricter reader (it rejects `.`/`..`/empty segments and anything
1255
+ // outside the client's own segment pattern), and routing the raw target
1256
+ // through the URL parser first would silently normalize a traversal
1257
+ // attempt into a different endpoint instead of refusing it.
1258
+ const rawTarget = String(req.url ?? "/");
1259
+ const pathname = rawTarget.startsWith("/") ? rawTarget : new URL(rawTarget, "http://x").pathname;
1217
1260
  const endpoint = endpointFromPath(pathname);
1218
1261
  if (endpoint === undefined) {
1219
1262
  sendEnvelope(res, 404, rpcError("invalid-request", "not-found", "not found", {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-git",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Complete Git management for the DeepSeek Harness Web GUI: branch switch, new-branch-from-base, fetch, pull, stage-all, commit (with AI-drafted message), push, status, and a click-to-open diff viewer (staged/unstaged, resizable) — as a collapsible floating panel that follows the current session's workspace.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "scripts": {
42
- "test": "node test/smoke.mjs && node test/host-mount.mjs && node test/generate.mjs && node test/render.mjs",
42
+ "test": "node test/smoke.mjs && node test/host-mount.mjs && node test/slot-mount.mjs && node test/generate.mjs && node test/render.mjs",
43
43
  "test:commit": "node test/commit.mjs",
44
44
  "test:diff": "node test/diff.mjs",
45
45
  "prepublishOnly": "npm test"
@@ -51,9 +51,12 @@
51
51
  "client": {
52
52
  "platform": "web",
53
53
  "inject": [
54
+ "@deepseek-ai/dsh-client-connection",
54
55
  "@deepseek-ai/dsh-client-locale",
55
56
  "@deepseek-ai/dsh-client-ui-conversation",
56
- "@deepseek-ai/dsh-client-ui-layout"
57
+ "@deepseek-ai/dsh-client-ui-layout",
58
+ "@deepseek-ai/dsh-client-ui-renderer",
59
+ "@deepseek-ai/dsh-client-ui-session"
57
60
  ]
58
61
  }
59
62
  },