@gatherthread/dsh-host 0.1.0-alpha.8 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,43 +7,45 @@ installation, GatherThread does not load in DSH or alter the GatherThread
7
7
  server, Web app, Codex bridge, or Claude adapter.
8
8
 
9
9
  The normal path is the published `@deepseek-ai/dsh` CLI, not a DSH source
10
- checkout. The verified distribution is `@deepseek-ai/dsh@0.1.2-rc.1` with the
11
- `web` profile and its public `sessionPersistence.list` recovery surface. The
12
- earlier source-only PoC remains fixed to `dsh-v0.1.3-alpha.1`, commit
13
- `d347e703908d0406b7a7ef80e3a0e594d86b2215`, and is only an advanced diagnostic
10
+ checkout. The verified distribution is `@deepseek-ai/dsh@0.1.7-rc.2` with the
11
+ `web` profile and its public `sessionPersistence` recovery surface. The
12
+ source-only path remains fixed to `dsh-v0.1.7-rc.2`, commit
13
+ `477b4f420553e8a52c2fbccc464d7561b239c443`, and is only an advanced diagnostic
14
14
  fallback. All version-sensitive Host calls remain inside `src/dsh-compat.ts`.
15
15
  The package does not install a DSH runtime dependency.
16
16
 
17
17
  ## Plugin-first connection
18
18
 
19
- After `@gatherthread/dsh-host` is published, the ordinary three-step path is:
19
+ Use **Connect DeepSeek Harness → Download packages for this server** in GatherThread to download its source-matched DSH plugin TGZ. Replace the example path below with the downloaded file's full path:
20
20
 
21
21
  ```text
22
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add @gatherthread/dsh-host@0.1.0-alpha.8
23
- npx @deepseek-ai/dsh@0.1.2-rc.1 web
22
+ npx @deepseek-ai/dsh@0.1.7-rc.2 plugin --profile web add "/absolute/path/to/LOCAL_DSH_PLUGIN.tgz"
23
+ npx @deepseek-ai/dsh@0.1.7-rc.2 web
24
24
  # In DSH: Settings -> GatherThread / 共序 -> Sign in and pair
25
25
  ```
26
26
 
27
+ On Windows, use a quoted path such as `C:/Users/you/Downloads/your-plugin.tgz`. The registry's `@gatherthread/dsh-host@0.1.0-beta.1` is an older build and does not include the current message-deletion safeguards. If server downloads are unavailable, [build the matching source checkout](../../docs/DSH_CONNECT.md#source-checkout-fallback); do not substitute the old npm build.
28
+
27
29
  Check the active CLI with `npx @deepseek-ai/dsh --version`. The normal start
28
- command already pins `0.1.2-rc.1`. DSH 0.1.2 does not
30
+ command already pins `0.1.7-rc.2`. The pinned DSH does not
29
31
  expose the root CLI version as a stable Host service, so the package does not
30
32
  inspect npx cache paths or claim runtime-version introspection. The install
31
33
  command pins the exact verified CLI, while every required Host/Client service is
32
34
  validated structurally and fails closed before a request can be claimed.
33
35
 
34
- `dsh plugin --profile web add` is DSH 0.1.2's official package installation
36
+ `dsh plugin --profile web add` is the pinned DSH release's official package installation
35
37
  mechanism. It initializes a missing `web` profile on first use, invokes pnpm,
36
- and appends only packages declaring `dsh.bundle`. DSH 0.1.2 has no verified
38
+ and appends only packages declaring `dsh.bundle`. The pinned DSH has no verified
37
39
  public plugin marketplace, marketplace index, or registration workflow. This
38
40
  repository change prepares the package but does not publish it or modify a real
39
41
  DSH profile.
40
42
 
41
- Inside DSH, choose the future GatherThread public service or enter one custom
43
+ Inside DSH, choose the GatherThread public service or enter one custom
42
44
  server origin. The same field covers an HTTPS LAN host, self-hosted deployment,
43
45
  or Tailscale address; plain HTTP is accepted only on loopback. The plugin opens
44
46
  an outbound HTTPS connection. A five-minute, single-use short code is confirmed
45
- through the existing GatherThread browser session or invitation identity.
46
- Public account registration is not assumed. The resulting long-lived device
47
+ through a signed-in GatherThread email account. Project invitations grant
48
+ membership only and cannot authorize pairing. Signup is closed by default. The resulting long-lived device
47
49
  grant is stored only by DSH's credential service and never appears in argv, a
48
50
  URL, browser storage, logs, status RPC, or shared history. Once paired, the
49
51
  plugin reconnects automatically whenever DSH starts.
@@ -52,7 +54,7 @@ Before removal, use **Disconnect** inside the DSH panel to clear the local
52
54
  grant, then run:
53
55
 
54
56
  ```text
55
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web remove @gatherthread/dsh-host
57
+ npx @deepseek-ai/dsh@0.1.7-rc.2 plugin --profile web remove @gatherthread/dsh-host
56
58
  ```
57
59
 
58
60
  The official command removes only this package dependency and profile layer;
@@ -60,12 +62,23 @@ other plugins remain untouched. The repository-level `npm run dsh:connect --
60
62
  --help` source-checkout connector and the examples under `bundle/` remain
61
63
  advanced/offline diagnostics, not the normal user path.
62
64
 
65
+ ## GitHub code synchronization
66
+
67
+ Beta 2 includes direct GitHub controls and requires matching server, Web app and native plugin releases. The project owner first connects an existing repository and base branch in Web. Install Git 2.38+ and GitHub CLI on the device, then open **Settings → GatherThread / 共序 → 项目代码 · GitHub(推荐)**. Click **连接 GitHub · 打开浏览器** to start local browser sign-in if needed, then authorize that exact project/repository configuration. `gh auth login --hostname github.com --git-protocol https --web` remains a local fallback. This consent is separate from GT Cloud and conversation sync; a new GitHub binding automatically uploads settled changes while idle and can be switched off. Configuration changes, including pause/resume, require renewed local authorization.
68
+
69
+ The Web app may request code operations only on your selected online runtime and cannot grant this local consent. Code and GitHub credentials travel directly to GitHub. GitHub permissions remain independent of GT membership; removing a GT member or project does not revoke GitHub access or delete GitHub data. See [Project code collaboration](../../docs/CODE_SYNC.md) for setup, recovery, limits and GitHub workflow-trigger risks.
70
+
71
+ GitHub 接入仍为**未发布源码预览**,需使用匹配的服务端、网页与原生插件源码构建,不能假定固定版本 npm 包已支持。在网页配置现有仓库后,每台设备安装 Git 2.38+ 与 GitHub CLI,再进入 **Settings → GatherThread / 共序 → 项目代码 · GitHub(推荐)**,点击“连接 GitHub · 打开浏览器”完成本机登录,并核对、逐项目授权。终端命令仍可作为保底。GitHub、GT Cloud 和会话同步分别授权;新 GitHub 绑定在 Agent 空闲时会自动上传稳定改动,可关闭且重连后保持关闭。配置变化及暂停后恢复需重新本地授权。网页不能替代本地文件授权,共序成员变化或项目删除不会撤销 GitHub 权限或删除 GitHub 数据。完整流程见[项目代码协作文档](../../docs/CODE_SYNC.md)。
72
+
63
73
  ## Security and recovery
64
74
 
65
- Only allowlisted public text/tool fields leave DSH. Reasoning blocks, raw model
66
- streams, request headers, replay state, tool-private metadata, and credentials
67
- are structurally unreachable from the upload mapper. Tool values are redacted
68
- and bounded. The connector holds one active request, persists a private atomic
75
+ Only allowlisted public text/tool fields leave DSH. An exact GatherThread-claimed
76
+ prompt can additionally stream native-exposed thinking and real plans. The live
77
+ listener verifies owned Agent, Session and execution; the npm fallback requires
78
+ DSH's genuine Agent-loop marker rather than a model-name match. Raw request
79
+ bodies, headers, replay state, tool-private metadata and unrelated native streams
80
+ stay outside the mapper. Tool arguments are parsed and redacted before upload;
81
+ incomplete JSON emits only a preparation marker. The connector holds one active request, persists a private atomic
69
82
  cursor/outbox, reuses DSH's official Session resume/repair path, and releases its
70
83
  listeners, timer, HTTP abort signal, Agent handle, and write ownership on unload.
71
84
 
@@ -106,9 +119,10 @@ cancels its queued permit before it can claim a request. Existing single-Session
106
119
  valid when `bindingMode` is omitted; both modes resolve the real current actor
107
120
  before applying Solo permissions.
108
121
 
109
- During an active prompt, newly durable DSH Host events queue a rate-limited,
110
- generic progress marker; event bursts coalesce. The marker carries no event text,
111
- reasoning, headers, or private stream data, but renews the exact claim attempt. Progress, completion,
122
+ During an active claimed prompt, selected native-visible answer, thinking, tool
123
+ and plan updates coalesce into bounded redacted snapshots. Generic activity
124
+ markers carry no native content and remain separate from presence. Accepted
125
+ progress renews the exact claim attempt. Progress, completion,
112
126
  and request-linked tool outbox entries are rebound to the renewed attempt after a
113
127
  crash recovery, while stale attempts remain fenced by the server.
114
128
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "@gatherthread/dsh-host",
4
- "version": "0.1.0-alpha.8",
4
+ "version": "0.1.0-beta.2",
5
5
  "license": "Apache-2.0",
6
6
  "defaultEnabled": false,
7
7
  "hostEntry": "@gatherthread/dsh-host",
@@ -15,15 +15,15 @@
15
15
  "primary": {
16
16
  "distribution": "npm",
17
17
  "package": "@deepseek-ai/dsh",
18
- "version": "0.1.2-rc.1",
18
+ "version": "0.1.7-rc.2",
19
19
  "profile": "web",
20
20
  "persistenceProbe": "list"
21
21
  },
22
22
  "sourceFallback": {
23
23
  "distribution": "source",
24
- "tag": "dsh-v0.1.3-alpha.1",
25
- "version": "0.1.3-alpha.1",
26
- "commit": "d347e703908d0406b7a7ef80e3a0e594d86b2215",
24
+ "tag": "dsh-v0.1.7-rc.2",
25
+ "version": "0.1.7-rc.2",
26
+ "commit": "477b4f420553e8a52c2fbccc464d7561b239c443",
27
27
  "verifiedProfiles": ["headless", "web"],
28
28
  "persistenceProbe": "stat"
29
29
  }
package/client/client.js CHANGED
@@ -35,6 +35,21 @@ function GatherThreadStatusPanel({ connection, sessions }) {
35
35
  const [provider, setProvider] = React.useState("");
36
36
  const [model, setModel] = React.useState("");
37
37
  const visibleSessionSignature = React.useRef("");
38
+ /** Recovery observed from both status polls and action responses. */
39
+ const lastRecoverableError = React.useRef(undefined);
40
+
41
+ const observeRouteRecovery = (next, configureResponse = false) => {
42
+ const recovery = next?.recoverableError;
43
+ // A failed configure is a new revoked choice even if the error code has not
44
+ // changed. Steady polls must leave an operator's new selection alone.
45
+ if (recovery === "route_unavailable"
46
+ && (configureResponse || lastRecoverableError.current !== "route_unavailable")) {
47
+ setCatalog(undefined);
48
+ setProvider("");
49
+ setModel("");
50
+ }
51
+ lastRecoverableError.current = recovery;
52
+ };
38
53
 
39
54
  React.useEffect(() => {
40
55
  let disposed = false;
@@ -60,6 +75,7 @@ function GatherThreadStatusPanel({ connection, sessions }) {
60
75
  }
61
76
  setSnapshot(result.snapshot);
62
77
  setNativeState(result.nativeState);
78
+ observeRouteRecovery(result.nativeState);
63
79
  setUnavailable(false);
64
80
  timer = setTimeout(refresh, POLL_INTERVAL_MS);
65
81
  }
@@ -95,17 +111,21 @@ function GatherThreadStatusPanel({ connection, sessions }) {
95
111
  const next = endpoint === "catalog/get" ? parseCatalog(value) : parseNativeState(value);
96
112
  if (endpoint === "catalog/get") {
97
113
  setCatalog(next);
98
- const firstProvider = next.providers[0];
99
- setProvider((current) => current || firstProvider?.id || "");
100
- setModel((current) => current || firstProvider?.models[0]?.id || "");
114
+ const selectedProvider = next.providers.find((entry) => entry.id === provider) ?? next.providers[0];
115
+ setProvider(selectedProvider?.id ?? "");
116
+ setModel((current) => selectedProvider?.models.some((entry) => entry.id === current)
117
+ ? current : selectedProvider?.models[0]?.id ?? "");
101
118
  } else {
119
+ observeRouteRecovery(next, endpoint === "connection/configure");
102
120
  setNativeState(next);
103
121
  setSnapshot(next.runtime);
104
122
  if (endpoint === "connection/configure") await sessions.refresh();
105
123
  }
106
124
  onSuccess?.(next);
107
125
  } catch {
108
- setNotice(endpoint.startsWith("code/")
126
+ setNotice(endpoint.startsWith("github/") && githubEnglish()
127
+ ? "GitHub sync did not finish. Check local sign-in, project access and the remote version; no files were forcibly overwritten."
128
+ : endpoint.startsWith("code/") || endpoint.startsWith("github/")
109
129
  ? "代码同步未完成。请检查本机授权、项目权限和云端版本;不会强制覆盖文件。"
110
130
  : endpoint.startsWith("sync/")
111
131
  ? "上传操作未完成。请检查连接和会话权限后重试。"
@@ -182,66 +202,115 @@ function GatherThreadStatusPanel({ connection, sessions }) {
182
202
  setDeviceName,
183
203
  setProvider,
184
204
  setModel,
205
+ setCatalog,
185
206
  runAction,
186
207
  }),
208
+ nativeState === undefined || unavailable ? null : renderCodeSyncControls(nativeState, busy, runAction, true),
187
209
  nativeState === undefined || unavailable ? null : renderCodeSyncControls(nativeState, busy, runAction));
188
210
  }
189
211
 
190
- function renderCodeSyncControls(state, busy, runAction) {
191
- if (!state.codeSync?.length) return null;
212
+ function githubEnglish() {
213
+ return typeof navigator !== "undefined" && typeof navigator.language === "string"
214
+ && !/^zh(?:-|$)/iu.test(navigator.language);
215
+ }
216
+
217
+ function renderCodeSyncControls(state, busy, runAction, github = false) {
218
+ const projects = github ? state.githubSync : state.codeSync;
219
+ if (!projects?.length) return null;
220
+ const label = (zh, en) => github && githubEnglish() ? en : zh;
192
221
  const actions = [
193
- ["code_sync_status", "检查状态"],
194
- ["code_upload", "上传代码"],
195
- ["code_download", "下载更新"],
196
- ["code_recover", "恢复到新目录"],
222
+ ["code_sync_status", label("检查状态", "Check status")],
223
+ ["code_upload", label("上传代码", "Upload code")],
224
+ ["code_download", label("下载更新", "Download updates")],
225
+ ["code_recover", label("恢复到新目录", "Recover to a new folder")],
197
226
  ];
198
- return React.createElement("section", { style: styles.connectionCard, "aria-label": "项目代码同步" },
199
- React.createElement("h3", { style: styles.sectionTitle }, "项目代码 · Git"),
227
+ if (github) actions.push(["code_update", label("从主分支更新", "Update from base branch")]);
228
+ const prefix = github ? "github_" : "";
229
+ return React.createElement("section", { style: styles.connectionCard, "aria-label": github ? label("GitHub 项目代码同步", "GitHub project code sync") : "项目代码同步" },
230
+ React.createElement("h3", { style: styles.sectionTitle }, github ? label("项目代码 · GitHub(推荐)", "Project code · GitHub (recommended)") : "项目代码 · GatherThread 云端"),
200
231
  React.createElement("p", { style: styles.muted },
201
- "代码与会话上传独立。每位成员使用自己的云端分支;项目成员均可读取代码。上传前请检查源文件,不上传密钥或私人资料。"),
202
- ...state.codeSync.map((project) => {
203
- const invoke = (action) => void runAction("code/action", { projectId: project.projectId, action });
232
+ github ? label("本机直接与 GitHub 同步,代码和 GitHub 凭据不经过 GatherThread。GitHub 仓库成员权限独立管理;自动上传可能触发仓库 Actions。", "This device syncs directly with GitHub; source and credentials do not pass through GatherThread. GitHub access is managed separately, and automatic pushes may trigger Actions.") : "代码与会话上传独立。每位成员使用自己的云端分支;项目成员均可读取代码。上传前请检查源文件,不上传密钥或私人资料。"),
233
+ github ? React.createElement("p", { style: styles.muted }, label("先在本机安装 Git 和 GitHub CLI;点击下方按钮会打开浏览器完成 GitHub 登录。", "Install Git and GitHub CLI on this device, then use the button below to open browser sign-in.")) : null,
234
+ ...projects.map((project) => {
235
+ const invoke = (action) => void runAction(github ? "github/action" : "code/action", { projectId: project.projectId, action: `${prefix}${action}` });
204
236
  const status = project.status;
237
+ const enabled = github ? project.connection?.enabled === true : status?.enabled;
205
238
  return React.createElement("details", { key: project.projectId, style: { ...styles.row, display: "block" } },
206
239
  React.createElement("summary", { style: styles.sessionTitle }, project.projectName),
207
240
  React.createElement("div", { style: styles.form },
241
+ github ? React.createElement("p", { style: styles.sessionMeta }, project.connection
242
+ ? `${project.connection.repository} · ${label("主分支", "Base")} ${project.connection.base_branch} · ${label("成员分支", "My branch")} ${project.branch ?? label("尚未分配", "not assigned")}${enabled ? "" : ` · ${label("已暂停", "paused")}`}`
243
+ : label("请由项目创建者在 GatherThread 的项目代码中绑定 GitHub 仓库。", "Ask the project owner to connect a GitHub repository in GatherThread project code settings.")) : null,
244
+ github ? React.createElement("button", { type: "button", style: styles.compactButton,
245
+ disabled: busy || !project.connection,
246
+ onClick: () => void runAction("github/connect", { projectId: project.projectId }),
247
+ }, label("连接 GitHub · 打开浏览器", "Connect GitHub · Open browser")) : null,
208
248
  React.createElement("label", { style: styles.syncToggle },
209
249
  React.createElement("input", {
210
- type: "checkbox", checked: project.authorized, disabled: busy,
211
- onChange: (event) => void runAction("code/authorize", {
250
+ type: "checkbox", checked: project.authorized, disabled: busy || (github && !project.authorized && !enabled),
251
+ onChange: (event) => void runAction(github ? "github/authorize" : "code/authorize", {
212
252
  projectId: project.projectId, enabled: event.target.checked,
253
+ ...(github && event.target.checked && project.connection ? {
254
+ repository: project.connection.repository, base_branch: project.connection.base_branch, revision: project.connection.revision,
255
+ } : {}),
213
256
  }),
214
- }), "允许此 DSH 同步该项目代码"),
215
- React.createElement("p", { style: styles.muted }, "仅访问此项目已经绑定的本地目录,不改动原 Git 分支或暂存区。下载要求本地没有未上传改动;恢复始终新建目录,不更换当前 Agent 工作目录。"),
257
+ }), github ? label("允许此 DSH 将本项目代码同步到上述 GitHub 仓库", "Allow this DSH to sync this project's code to the GitHub repository above") : "允许此 DSH 同步该项目代码"),
258
+ React.createElement("p", { style: styles.muted }, label("仅访问此项目已经绑定的本地目录,不改动原 Git 分支或暂存区。下载要求本地没有未上传改动;恢复始终新建目录,不更换当前 Agent 工作目录。", "Only the local folder already bound to this project is accessed. Your original Git branch and index stay unchanged. Downloads require no unuploaded changes; recovery creates a separate folder.")),
216
259
  status ? React.createElement("p", { style: styles.sessionMeta },
217
- status.local_status_unknown ? "已恢复云端副本;原工作区状态未知,请单独检查。" : status.enabled
218
- ? `${status.file_count} 个源文件 · ${status.local_changes} 项待上传 · ${status.excluded_count} 项已排除 · 云端 ${status.cloud_commit?.slice(0, 8) ?? "尚无版本"}`
219
- : "请先由项目创建者在 GatherThread 的「项目代码」中启用 Git。") : null,
220
- status?.needs_download ? React.createElement("p", { style: styles.notice }, "云端有新版本。请先下载;本地有改动时请恢复到新目录比较,不会自动覆盖。") : null,
260
+ status.local_status_unknown ? label("已恢复云端副本;原工作区状态未知,请单独检查。", "A remote copy was recovered. Review the original workspace separately; its state is unknown.") : status.enabled
261
+ ? `${status.file_count} ${label("个源文件", "source files")} · ${status.local_changes} ${label("项待上传", "pending changes")} · ${status.excluded_count} ${label("项已排除", "excluded")} · ${label("云端", "remote")} ${status.cloud_commit?.slice(0, 8) ?? label("尚无版本", "no version yet")}`
262
+ : github ? label("GitHub 同步已暂停,请由项目创建者恢复。", "GitHub sync is paused; ask the project owner to resume it.") : "请先由项目创建者在 GatherThread 的「项目代码」中启用 Git。") : null,
263
+ status?.needs_download ? React.createElement("p", { style: styles.notice }, label("云端有新版本。请先下载;本地有改动时请恢复到新目录比较,不会自动覆盖。", "A newer remote version exists. Download when your local files are clean; otherwise recover to a new folder for comparison.")) : null,
221
264
  project.authorized ? React.createElement("label", { style: styles.syncToggle },
222
265
  React.createElement("input", {
223
266
  type: "checkbox", checked: status?.automatic_upload === true,
224
- disabled: busy || !status?.enabled,
267
+ disabled: busy || (!enabled && !status?.automatic_upload),
225
268
  onChange: (event) => invoke(event.target.checked ? "code_auto_upload_enable" : "code_auto_upload_disable"),
226
- }), "空闲时自动上传本地代码至云端") : null,
227
- React.createElement("div", { style: styles.syncActions }, ...actions.map(([action, label]) => (
269
+ }), github ? label("空闲时自动上传本地代码至 GitHub", "Automatically upload local code to GitHub while idle") : "空闲时自动上传本地代码至云端") : null,
270
+ React.createElement("div", { style: styles.syncActions }, ...actions.map(([action, actionLabel]) => (
228
271
  React.createElement("button", {
229
272
  key: action, type: "button", style: styles.compactButton,
230
- disabled: busy || !project.authorized || (action !== "code_sync_status" && !status?.enabled),
273
+ disabled: busy || (!project.authorized && !(github && action === "code_sync_status")) || (action !== "code_sync_status" && !enabled),
231
274
  onClick: () => {
232
- if (action === "code_recover" && !window.confirm("将云端代码恢复到当前项目旁的新目录,原目录和会话保持不变。继续?")) return;
275
+ if (action === "code_recover" && !window.confirm(label("将云端代码恢复到当前项目旁的新目录,原目录和会话保持不变。继续?", "Recover remote code to a new sibling folder? The original folder and conversations stay unchanged."))) return;
233
276
  invoke(action);
234
277
  },
235
- }, label)
278
+ }, actionLabel)
236
279
  ))),
237
280
  status?.recovery_directory ? React.createElement("p", { role: "status", style: styles.notice },
238
- `已恢复至项目同级目录:${status.recovery_directory}。在本地 Agent 中打开该目录继续工作;原会话保持不变。`) : null,
239
- project.error ? React.createElement("p", { role: "status", style: styles.notice }, codeSyncErrorLabel(project.error)) : null,
281
+ `${label("已恢复至项目同级目录:", "Recovered to a sibling folder: ")}${status.recovery_directory}${label("。在本地 Agent 中打开该目录继续工作;原会话保持不变。", ". Open that folder in your local Agent to continue; original conversations remain unchanged.")}`) : null,
282
+ project.error ? React.createElement("p", { role: "status", style: styles.notice }, codeSyncErrorLabel(project.error, github)) : null,
240
283
  ));
241
284
  }));
242
285
  }
243
286
 
244
- function codeSyncErrorLabel(code) {
287
+ function codeSyncErrorLabel(code, github = false) {
288
+ if (github && githubEnglish()) {
289
+ const labels = {
290
+ code_sync_disabled: "Allow this DSH to sync project code first.",
291
+ code_sync_busy: "The Agent is working. Try again after this turn finishes.",
292
+ code_workspace_busy: "The Agent is working. Try again after this turn finishes.",
293
+ code_sync_dirty: "Local changes are not uploaded. Upload them first, or recover remote code to a new folder for comparison.",
294
+ code_sync_recovery_required: "The local folder is missing or a download was interrupted. Recover remote code to a new folder first.",
295
+ code_sync_forbidden: "Code access was denied. Check this device's sign-in and project permissions.",
296
+ code_sync_conflict: "The remote version changed. Download safely or recover to a new folder; nothing was overwritten.",
297
+ code_conflict: "The remote version conflicted. Check the latest branch while preserving local changes.",
298
+ code_stale_head: "A newer remote version exists. Recover to a new folder before comparing changes.",
299
+ code_merge_conflict: "Branches conflict. Preserve local changes and resolve the conflict after comparing versions.",
300
+ code_git_unavailable: "Git is unavailable. Check this device's Git installation.",
301
+ code_secret_detected: "A possible secret was detected, so the upload was blocked. Check project files.",
302
+ code_sync_secret: "A possible secret was detected, so the upload was blocked. Check project files.",
303
+ code_github_binding_changed: "The GitHub repository changed. Review the repository and base branch, then authorize again.",
304
+ code_github_not_configured: "Ask the project owner to connect a GitHub repository first.",
305
+ code_github_unavailable: "GitHub settings are temporarily unavailable. Check the connection and retry.",
306
+ code_sync_binding: "The code sync target changed. Review the repository and local authorization.",
307
+ code_sync_unavailable: "Cannot connect to GitHub. Check Git, GitHub CLI sign-in, network and repository access on this device.",
308
+ code_sync_limit: "Project files exceed this sync method's safety limits. Check large files and exclusions.",
309
+ code_sync_unsupported: "This repository contains unsupported content; no code was changed.",
310
+ code_sync_unsafe_path: "The project contains an unsafe or unsupported path. Sync was stopped.",
311
+ };
312
+ return labels[code] ?? "Code sync did not finish. Check authorization, files and remote status; conversation sync is unaffected.";
313
+ }
245
314
  const labels = {
246
315
  code_sync_disabled: "请先允许此 DSH 同步项目代码。",
247
316
  code_sync_busy: "Agent 正在工作,请等待本轮结束后再同步。",
@@ -259,6 +328,14 @@ function codeSyncErrorLabel(code) {
259
328
  code_secret_detected: "检测到可能的密钥,已阻止上传。请检查项目文件。",
260
329
  code_not_enabled: "请先由项目创建者在 GatherThread 启用项目 Git。",
261
330
  code_sync_secret: "检测到可能的密钥,已阻止上传。请检查项目文件。",
331
+ code_github_binding_changed: "GitHub 仓库配置已变化,请核对最新仓库和主分支后重新授权。",
332
+ code_github_not_configured: "请先由项目创建者绑定 GitHub 仓库。",
333
+ code_github_unavailable: "GitHub 配置信息暂不可用,请检查连接后重试。",
334
+ code_sync_binding: "代码同步绑定已变化。请核对当前仓库与本地授权后重试。",
335
+ code_sync_unavailable: github ? "无法连接 GitHub。请检查本机 Git、GitHub CLI 登录状态、网络和仓库访问权限。" : "代码同步暂不可用,请检查本机 Git 与服务连接。",
336
+ code_sync_limit: "项目文件超过同步限制,未上传。请检查大文件和排除范围。",
337
+ code_sync_unsupported: "仓库包含当前同步方式不支持的内容,未修改代码。",
338
+ code_sync_unsafe_path: "项目含不安全或不支持的文件路径,已停止同步。",
262
339
  };
263
340
  return labels[code] ?? "代码同步未完成。请检查授权、文件和云端状态后重试;会话同步不受影响。";
264
341
  }
@@ -377,8 +454,9 @@ function renderNativeControls(input) {
377
454
  );
378
455
  } else if (state.route === undefined) {
379
456
  const providers = input.catalog?.providers ?? [];
380
- const selectedProvider = providers.find((entry) => entry.id === input.provider) ?? providers[0];
457
+ const selectedProvider = providers.find((entry) => entry.id === input.provider);
381
458
  const models = selectedProvider?.models ?? [];
459
+ const validSelection = models.some((entry) => entry.id === input.model);
382
460
  controls.push(
383
461
  connectionSummary(state),
384
462
  // Reaching this branch means authorization is already "paired": the
@@ -387,7 +465,12 @@ function renderNativeControls(input) {
387
465
  // is registered, so a bare stopped connection is indistinguishable from a
388
466
  // fresh install. State the remaining step explicitly.
389
467
  React.createElement("p", { key: "select-model-notice", style: styles.notice },
390
- "配对完成后,尚未选择 DSH Provider 与 Model。完成选择并点击下方按钮后,本机 DSH 运行时才会注册到 GatherThread;在此之前,GatherThread 网页无法发现这台 DSH。"),
468
+ // A dropped route is not the same situation as a fresh pairing, and saying
469
+ // so is the whole point of not repointing the model silently: the reader has
470
+ // to know their previous choice no longer exists before picking another.
471
+ state.recoverableError === "route_unavailable"
472
+ ? "DSH 当前已不再提供此前为该连接选择的模型,因此该连接已停止并取消注册。请重新选择 Provider 与 Model;在此之前的旧模型不会被自动替换。"
473
+ : "配对完成后,尚未选择 DSH Provider 与 Model。完成选择并点击下方按钮后,本机 DSH 运行时才会注册到 GatherThread;在此之前,GatherThread 网页无法发现这台 DSH。"),
391
474
  input.catalog === undefined
392
475
  ? React.createElement("button", {
393
476
  key: "load-catalog",
@@ -401,6 +484,7 @@ function renderNativeControls(input) {
401
484
  style: styles.form,
402
485
  onSubmit: (event) => {
403
486
  event.preventDefault();
487
+ if (input.busy || !validSelection) return;
404
488
  void input.runAction("connection/configure", {
405
489
  provider: input.provider,
406
490
  model: input.model,
@@ -429,7 +513,7 @@ function renderNativeControls(input) {
429
513
  )),
430
514
  React.createElement("button", {
431
515
  type: "submit",
432
- disabled: input.busy || !input.provider || !input.model,
516
+ disabled: input.busy || !validSelection,
433
517
  style: styles.primaryButton,
434
518
  }, input.busy ? "正在连接…" : "连接全部可访问项目")),
435
519
  disconnectButton(input),
@@ -482,11 +566,12 @@ function selectControl(entries, value, setValue, keyOf, labelOf) {
482
566
  return React.createElement("select", {
483
567
  required: true,
484
568
  value,
485
- style: styles.input,
569
+ style: styles.select,
486
570
  onChange: (event) => setValue(event.target.value),
487
571
  }, ...entries.map((entry) => React.createElement("option", {
488
572
  key: keyOf(entry),
489
573
  value: keyOf(entry),
574
+ style: styles.option,
490
575
  }, labelOf(entry))));
491
576
  }
492
577
 
@@ -499,13 +584,13 @@ function detail(label, value) {
499
584
  function parseNativeState(value) {
500
585
  exactObject(value, [
501
586
  "schemaVersion", "integration", "authorization", "compatibility", "runtime",
502
- "officialServerUrl", "serverUrl", "deviceName", "route", "projectCount", "bindings", "localSync", "codeSync", "pairing", "recoverableError",
587
+ "officialServerUrl", "serverUrl", "deviceName", "route", "projectCount", "bindings", "localSync", "codeSync", "githubSync", "pairing", "recoverableError",
503
588
  ]);
504
589
  if (value.schemaVersion !== 2 || value.integration !== "gatherthread") throw new Error("invalid native status identity");
505
590
  if (!AUTHORIZATION_STATES.has(value.authorization)) throw new Error("invalid native authorization state");
506
591
  exactObject(value.compatibility, ["package", "version", "profile"]);
507
592
  if (value.compatibility.package !== "@deepseek-ai/dsh"
508
- || value.compatibility.version !== "0.1.2-rc.1"
593
+ || value.compatibility.version !== "0.1.7-rc.2"
509
594
  || value.compatibility.profile !== "web") throw new Error("incompatible native DSH Host");
510
595
  const runtime = parseSnapshot(value.runtime);
511
596
  const optionalUrl = (url) => {
@@ -524,6 +609,7 @@ function parseNativeState(value) {
524
609
  let bindings;
525
610
  let localSync;
526
611
  let codeSync;
612
+ let githubSync;
527
613
  if (value.route !== undefined || value.bindings !== undefined || value.projectCount !== undefined) {
528
614
  if (value.route === undefined || !Number.isSafeInteger(value.projectCount) || value.projectCount < 0
529
615
  || !Array.isArray(value.bindings) || value.bindings.length > 100
@@ -564,17 +650,34 @@ function parseNativeState(value) {
564
650
  }
565
651
  return { ...sync };
566
652
  });
567
- const codeSyncValue = value.codeSync ?? [];
653
+ const parseCodeProjects = (codeSyncValue, github = false) => {
568
654
  if (!Array.isArray(codeSyncValue) || codeSyncValue.length > 100) throw new Error("invalid code sync status");
569
- codeSync = codeSyncValue.map((project) => {
570
- exactObject(project, ["projectId", "projectName", "authorized", "status", "error"]);
655
+ return codeSyncValue.map((project) => {
656
+ exactObject(project, ["projectId", "projectName", "authorized", "status", "error", ...(github ? ["connection", "branch"] : [])]);
571
657
  boundedText(project.projectId, 128);
572
658
  boundedText(project.projectName, 160);
573
659
  if (typeof project.authorized !== "boolean") throw new Error("invalid code sync authorization");
574
- if (project.error !== undefined && !/^code_[a-z_]{1,60}$/u.test(project.error)) throw new Error("invalid code sync error");
660
+ if (project.error !== undefined && !/^(?:github_)?code_[a-z_]{1,60}$/u.test(project.error)) throw new Error("invalid code sync error");
661
+ if (github) {
662
+ if (project.connection !== null) {
663
+ exactObject(project.connection, ["repository", "base_branch", "revision", "enabled"]);
664
+ boundedText(project.connection.repository, 160);
665
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(project.connection.repository)) throw new Error("invalid GitHub repository");
666
+ boundedText(project.connection.base_branch, 255);
667
+ boundedText(project.connection.revision, 128);
668
+ if (typeof project.connection.enabled !== "boolean") throw new Error("invalid GitHub state");
669
+ }
670
+ if (project.branch !== null) boundedText(project.branch, 255);
671
+ }
575
672
  if (project.status !== undefined) {
576
673
  const status = project.status;
577
- exactObject(status, ["enabled", "automatic_upload", "local_changes", "file_count", "excluded_count", "base_commit", "cloud_commit", "branch_id", "needs_download", "recovery_directory", "local_status_unknown"]);
674
+ exactObject(status, ["enabled", "automatic_upload", "local_changes", "file_count", "excluded_count", "base_commit", "cloud_commit", "branch_id", "needs_download", "recovery_directory", "local_status_unknown", ...(github ? ["provider", "repository", "branch", "base_branch"] : [])]);
675
+ if (github) {
676
+ if (status.provider !== undefined && status.provider !== "github") throw new Error("invalid code provider");
677
+ if (status.repository !== undefined && status.repository !== project.connection?.repository) throw new Error("GitHub target mismatch");
678
+ if (status.base_branch !== undefined && status.base_branch !== project.connection?.base_branch) throw new Error("GitHub base mismatch");
679
+ if (status.branch !== undefined) boundedText(status.branch, 255);
680
+ }
578
681
  if (status.local_status_unknown !== undefined && typeof status.local_status_unknown !== "boolean") throw new Error("invalid local status flag");
579
682
  for (const key of ["enabled", "automatic_upload", "needs_download"]) {
580
683
  if (typeof status[key] !== "boolean") throw new Error("invalid code sync flag");
@@ -593,6 +696,9 @@ function parseNativeState(value) {
593
696
  }
594
697
  return { ...project };
595
698
  });
699
+ };
700
+ codeSync = parseCodeProjects(value.codeSync ?? []);
701
+ githubSync = parseCodeProjects(value.githubSync ?? [], true);
596
702
  }
597
703
  let pairing;
598
704
  if (value.pairing !== undefined) {
@@ -618,7 +724,8 @@ function parseNativeState(value) {
618
724
  }
619
725
  if (value.recoverableError !== undefined
620
726
  && value.recoverableError !== "pairing_failed"
621
- && value.recoverableError !== "connection_failed") throw new Error("invalid native recovery state");
727
+ && value.recoverableError !== "connection_failed"
728
+ && value.recoverableError !== "route_unavailable") throw new Error("invalid native recovery state");
622
729
  return {
623
730
  schemaVersion: 2,
624
731
  integration: "gatherthread",
@@ -628,7 +735,7 @@ function parseNativeState(value) {
628
735
  ...(officialServerUrl === undefined ? {} : { officialServerUrl }),
629
736
  ...(serverUrl === undefined ? {} : { serverUrl }),
630
737
  ...(value.deviceName === undefined ? {} : { deviceName: value.deviceName }),
631
- ...(route === undefined ? {} : { route, projectCount: value.projectCount, bindings, localSync, codeSync }),
738
+ ...(route === undefined ? {} : { route, projectCount: value.projectCount, bindings, localSync, codeSync, githubSync }),
632
739
  ...(pairing === undefined ? {} : { pairing }),
633
740
  ...(value.recoverableError === undefined ? {} : { recoverableError: value.recoverableError }),
634
741
  };
@@ -766,6 +873,14 @@ const styles = {
766
873
  field: { display: "grid", gap: 6 },
767
874
  fieldLabel: { fontSize: 12, fontWeight: 600, opacity: 0.76 },
768
875
  input: { width: "100%", minHeight: 36, boxSizing: "border-box", padding: "7px 10px", borderRadius: 9, border: "1px solid rgba(127,127,127,.28)", background: "transparent", color: "inherit", font: "inherit" },
876
+ // A dropdown owns a popup it cannot inherit: the list surface is painted from the
877
+ // control's own background and each row from its option, while the text keeps
878
+ // following the panel. Sharing `input` therefore drew the model names in the
879
+ // panel's foreground on the UA's own surface — white on white under a dark harness
880
+ // theme. Both the control and the rows name the system field pair instead, which
881
+ // the UA resolves against the used color scheme, so neither can match the other.
882
+ select: { width: "100%", minHeight: 36, boxSizing: "border-box", padding: "7px 10px", borderRadius: 9, border: "1px solid rgba(127,127,127,.28)", background: "Field", color: "FieldText", font: "inherit" },
883
+ option: { background: "Field", color: "FieldText" },
769
884
  primaryButton: { minHeight: 36, padding: "8px 12px", borderRadius: 9, border: "1px solid Highlight", background: "Highlight", color: "HighlightText", WebkitTextFillColor: "HighlightText", font: "inherit", fontWeight: 600, cursor: "pointer" },
770
885
  secondaryButton: { minHeight: 36, padding: "8px 12px", borderRadius: 9, border: "1px solid rgba(127,127,127,.3)", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" },
771
886
  linkButton: { display: "inline-flex", alignItems: "center", justifyContent: "center", minHeight: 36, padding: "0 12px", borderRadius: 9, border: "1px solid rgba(127,127,127,.3)", color: "inherit", textDecoration: "none", fontWeight: 600 },