@linxin666/dsh-client-ui-task-board 0.2.4 → 0.2.6

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: e9bcccc740d89fdd20a5142efa96dfe79ec842ab
6
- README.zh.md: bd1b15821bda1577565044c8f9a8e34c1aadfb82
5
+ README.md: 359b35160b1cf7a8c697152b4e3fc60193ed3599
6
+ README.zh.md: 535ac2b5c8b4c6fdbff61c08b9cadae3b5679cc7
package/README.md CHANGED
@@ -18,7 +18,7 @@ A hot-pluggable DeepSeek Harness (DSH) Web GUI plugin with a Host-authoritative
18
18
  - **Deterministic recovery**: a running execution with a recorded session is observed after restart; an interrupted start without a session id is cancelled and is not resent.
19
19
  - **Live synchronization**: mutations return a full revisioned snapshot; SSE announces revision, scheduler, and power changes, while reconnect and page visibility recovery fetch a full snapshot.
20
20
  - **Optional idle-sleep protection**: off by default; when enabled it covers every running DSH session, enabled non-archived task-board schedules, and unknown session state.
21
- - **System-prompt injection**: the Host registers a `plugin:task-board` section (order 200) through `SystemPrompt.section`, and the task-board settings can disable the announcement without disabling the board.
21
+ - **System-prompt injection**: the Host registers a `plugin:task-board` section (order 200) through `SystemPrompt.section`, and the task-board settings can disable the announcement without disabling the board. The guidance also reminds agents to close any visible `todo_write` plan before the final answer.
22
22
 
23
23
  ## Architecture and protocol
24
24
 
package/README.zh.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - **确定性恢复**:已有 session id 的 running execution 在重启后继续观察;没有 session id 的启动中断会取消且不会重发。
19
19
  - **实时同步**:变更返回完整 revision snapshot;SSE 只提示 revision、scheduler 与 power 变化,重连和页面恢复可见时重新拉完整 snapshot。
20
20
  - **可选空闲睡眠保护**:默认关闭;开启后覆盖全部运行中的 DSH 会话、已启用且未归档的任务计划和未知会话状态。
21
- - **系统提示词注入**:Host 通过 `SystemPrompt.section` 注册 order 200 的 `plugin:task-board` 段;任务看板设置可单独关闭声明而不关闭看板。
21
+ - **系统提示词注入**:Host 通过 `SystemPrompt.section` 注册 order 200 的 `plugin:task-board` 段;任务看板设置可单独关闭声明而不关闭看板。该提示也会提醒 agent 在最终回复前收尾可见的 `todo_write` 计划列表。
22
22
 
23
23
  ## 架构与协议
24
24
 
package/lib/client.js CHANGED
@@ -2908,7 +2908,7 @@ window.__ModuleLoader__.load({
2908
2908
  }
2909
2909
  //#endregion
2910
2910
  //#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
2911
- const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
2911
+ const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
2912
2912
  const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
2913
2913
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2914
2914
  const tag = document.createElement("style");
package/lib/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
+ import { spawn, spawnSync } from "node:child_process";
3
4
  import { createHash, timingSafeEqual } from "node:crypto";
4
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
6
  import { dirname, isAbsolute, join, win32 } from "node:path";
6
7
  import { homedir } from "node:os";
7
- import { spawn, spawnSync } from "node:child_process";
8
8
  //#region src/core/schedule.ts
9
9
  /** Inclusive ranges per field, in cron order. */
10
10
  const FIELD_RANGES = [
@@ -861,6 +861,69 @@ function processIsAlive(pid) {
861
861
  return error.code !== "ESRCH";
862
862
  }
863
863
  }
864
+ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
865
+ let ownStartTime;
866
+ let ownStartTimeResolved = false;
867
+ /**
868
+ * Best-effort start time (Unix epoch ms) of a live process. Used to prove
869
+ * whether the ledger lock really belongs to the PID recorded in it, so a
870
+ * crash leftover whose PID was reused by an unrelated process (issue #786)
871
+ * is detected as stale instead of blocking startup forever. Returns
872
+ * undefined when the platform probe is unavailable; callers fail closed.
873
+ */
874
+ function processStartTimeMs(pid) {
875
+ if (process.platform === "win32") {
876
+ const probe = spawnSync("powershell", [
877
+ "-NoProfile",
878
+ "-NonInteractive",
879
+ "-Command",
880
+ "[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()"
881
+ ], {
882
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
883
+ windowsHide: true
884
+ });
885
+ if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
886
+ const started = Number(probe.stdout.toString("utf8").trim());
887
+ return Number.isFinite(started) ? started : void 0;
888
+ }
889
+ const env = {
890
+ ...process.env,
891
+ LC_ALL: "C"
892
+ };
893
+ const probe = spawnSync("ps", [
894
+ "-o",
895
+ "lstart=",
896
+ "-p",
897
+ String(pid)
898
+ ], {
899
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
900
+ env
901
+ });
902
+ if (probe.status === 0 && probe.stdout.length > 0) {
903
+ const started = Date.parse(probe.stdout.toString("utf8").trim());
904
+ if (Number.isFinite(started)) return started;
905
+ }
906
+ const elapsed = spawnSync("ps", [
907
+ "-o",
908
+ "etimes=",
909
+ "-p",
910
+ String(pid)
911
+ ], {
912
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
913
+ env
914
+ });
915
+ if (elapsed.status !== 0 || elapsed.stdout.length === 0) return void 0;
916
+ const seconds = Number(elapsed.stdout.toString("utf8").trim());
917
+ if (!Number.isFinite(seconds)) return void 0;
918
+ return Date.now() - seconds * 1e3;
919
+ }
920
+ function ownProcessStartTimeMs() {
921
+ if (!ownStartTimeResolved) {
922
+ ownStartTimeResolved = true;
923
+ ownStartTime = processStartTimeMs(process.pid);
924
+ }
925
+ return ownStartTime;
926
+ }
864
927
  function betterExecution(a, b) {
865
928
  if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
866
929
  if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
@@ -1308,7 +1371,8 @@ var HostTaskLedger = class {
1308
1371
  const fd = openSync(this.lockFile, "wx", 384);
1309
1372
  writeFileSync(fd, JSON.stringify({
1310
1373
  pid: process.pid,
1311
- token: this.lockToken
1374
+ token: this.lockToken,
1375
+ startedAt: ownProcessStartTimeMs()
1312
1376
  }), { encoding: "utf8" });
1313
1377
  fsyncSync(fd);
1314
1378
  try {
@@ -1318,13 +1382,27 @@ var HostTaskLedger = class {
1318
1382
  } catch (error) {
1319
1383
  if (error.code !== "EEXIST") throw error;
1320
1384
  let pid;
1385
+ let ownerStartedAt;
1321
1386
  try {
1322
1387
  const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
1323
1388
  if (typeof owner.pid === "number") pid = owner.pid;
1389
+ if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
1324
1390
  } catch {
1325
1391
  throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
1326
1392
  }
1327
- if (pid !== void 0 && processIsAlive(pid)) throw new Error(`task-board ledger is already owned by process ${pid}`);
1393
+ if (pid !== void 0 && processIsAlive(pid)) {
1394
+ const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
1395
+ if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt !== ownerStartedAt : (() => {
1396
+ try {
1397
+ return statSync(this.lockFile).mtimeMs < actualStartedAt;
1398
+ } catch {
1399
+ return true;
1400
+ }
1401
+ })()))) {
1402
+ const hint = ownerStartedAt !== void 0 && actualStartedAt === ownerStartedAt ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
1403
+ throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
1404
+ }
1405
+ }
1328
1406
  try {
1329
1407
  unlinkSync(this.lockFile);
1330
1408
  } catch (unlinkError) {
@@ -1361,8 +1439,10 @@ function isErrorTurnEnd(data) {
1361
1439
  }
1362
1440
  var HostExecutionRunner = class {
1363
1441
  api;
1364
- constructor(api) {
1442
+ commands;
1443
+ constructor(api, commands) {
1365
1444
  this.api = api;
1445
+ this.commands = commands;
1366
1446
  }
1367
1447
  async launch(task) {
1368
1448
  if (task.workspaceId !== void 0) {
@@ -1390,16 +1470,10 @@ var HostExecutionRunner = class {
1390
1470
  }));
1391
1471
  if (!renamed.result.ok) throw failure(renamed.result.error);
1392
1472
  if (task.permission !== void 0) {
1393
- const command = await this.api.sessions.prompt(request({
1394
- sessionId,
1395
- mode: "queue",
1396
- content: [{
1397
- type: "text",
1398
- text: `/permission ${task.permission}`
1399
- }]
1400
- }));
1401
- if (!command.result.ok) throw failure(command.result.error);
1402
- if (command.result.value.command?.kind !== "success") throw new Error("permission command was not acknowledged");
1473
+ if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
1474
+ const command = await this.commands.execute(sessionId, `/permission ${task.permission}`, AbortSignal.timeout(3e4));
1475
+ if (command === void 0) throw new Error("permission command was not acknowledged");
1476
+ if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
1403
1477
  }
1404
1478
  const prompt = await this.api.sessions.prompt(request({
1405
1479
  sessionId,
@@ -1805,7 +1879,7 @@ var TaskBoardHostService = class {
1805
1879
  now;
1806
1880
  constructor(api, options = {}) {
1807
1881
  this.ledger = options.ledger ?? new HostTaskLedger();
1808
- this.runner = new HostExecutionRunner(api);
1882
+ this.runner = new HostExecutionRunner(api, options.commandDispatcher);
1809
1883
  this.power = options.power ?? new PowerInhibitor();
1810
1884
  this.now = options.now ?? Date.now;
1811
1885
  this.ledger.subscribe(() => {
@@ -2277,10 +2351,12 @@ const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
2277
2351
  const inject = [
2278
2352
  "systemPrompt",
2279
2353
  "apiProxy",
2280
- "webServer"
2354
+ "webServer",
2355
+ "agents",
2356
+ "commands"
2281
2357
  ];
2282
2358
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2283
- const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
2359
+ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。";
2284
2360
  /**
2285
2361
  * Settings namespace of the board's announcement capability — the section the
2286
2362
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -2319,7 +2395,11 @@ const DEFAULT_ANNOUNCE = true;
2319
2395
  */
2320
2396
  const apply = mountOnce("@linxin666/dsh-client-ui-task-board", applyImpl);
2321
2397
  function applyImpl(ctx, config) {
2322
- const host = new TaskBoardHostService(ctx.apiProxy);
2398
+ const host = new TaskBoardHostService(ctx.apiProxy, { commandDispatcher: { async execute(sessionId, line, signal) {
2399
+ const agent = ctx.agents.get(sessionId);
2400
+ if (agent === void 0) throw new Error(`execution session ${sessionId} is not available`);
2401
+ return (await ctx.commands.execute(agent, line, [], signal))?.result;
2402
+ } } });
2323
2403
  host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false);
2324
2404
  host.start();
2325
2405
  ctx.effect(() => {
@@ -1,9 +1,16 @@
1
1
  import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
2
+ import type { CommandResult } from '@deepseek-ai/dsh-commands/types';
2
3
  import type { TaskRecord } from './core/tasks.ts';
3
4
  /** One session-list row, extracted from the sessions.list RPC result. */
4
5
  export type SessionSummary = Extract<Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'], {
5
6
  ok: true;
6
7
  }>['value']['items'][number];
8
+ type ExecutionSessionId = Extract<Awaited<ReturnType<ApiProxy['sessions']['create']>>['result'], {
9
+ ok: true;
10
+ }>['value']['sessionId'];
11
+ export interface SessionCommandDispatcher {
12
+ execute(sessionId: ExecutionSessionId, line: string, signal: AbortSignal): Promise<CommandResult | undefined>;
13
+ }
7
14
  export type ExecutionInspection = {
8
15
  outcome: 'pending';
9
16
  } | {
@@ -22,7 +29,8 @@ export declare class SessionLaunchError extends Error {
22
29
  }
23
30
  export declare class HostExecutionRunner {
24
31
  private readonly api;
25
- constructor(api: ApiProxy);
32
+ private readonly commands?;
33
+ constructor(api: ApiProxy, commands?: SessionCommandDispatcher | undefined);
26
34
  launch(task: TaskRecord): Promise<string>;
27
35
  listRunning(): Promise<{
28
36
  known: true;
@@ -39,3 +47,4 @@ export declare class HostExecutionRunner {
39
47
  */
40
48
  inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
41
49
  }
50
+ export {};
@@ -1,6 +1,6 @@
1
1
  import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
2
2
  import { HostTaskLedger } from './host-ledger.ts';
3
- import { HostExecutionRunner } from './host-runner.ts';
3
+ import { HostExecutionRunner, type SessionCommandDispatcher } from './host-runner.ts';
4
4
  import { PowerInhibitor } from './power-inhibitor.ts';
5
5
  import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts';
6
6
  export declare class TaskBoardHostService {
@@ -21,6 +21,7 @@ export declare class TaskBoardHostService {
21
21
  ledger?: HostTaskLedger;
22
22
  power?: PowerInhibitor;
23
23
  now?: () => number;
24
+ commandDispatcher?: SessionCommandDispatcher;
24
25
  });
25
26
  start(): void;
26
27
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
@@ -11,7 +11,7 @@ import z from 'schemastery';
11
11
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
12
12
  export declare const inject: string[];
13
13
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
14
- export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
14
+ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002\u82E5\u4F60\u540C\u65F6\u7528 todo_write \u7EF4\u62A4\u4F1A\u8BDD\u9876\u90E8\u7684\u53EF\u89C1\u8BA1\u5212\u5217\u8868\uFF0C\u6700\u7EC8\u56DE\u590D\u524D\u5FC5\u987B\u518D\u6B21\u8C03\u7528 todo_write \u6536\u5C3E\uFF1A\u6CA1\u6709\u5269\u4F59\u5DE5\u4F5C\u65F6\u4E0D\u8981\u4FDD\u7559 in_progress\uFF0C\u5DF2\u5B8C\u6210\u7684\u6700\u540E\u4E00\u6B65\u8981\u6807\u4E3A completed\u3002";
15
15
  /**
16
16
  * Settings namespace of the board's announcement capability — the section the
17
17
  * web settings surface edits. Spelled here rather than imported: the browser
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -35,6 +35,8 @@
35
35
  }
36
36
  },
37
37
  "peerDependencies": {
38
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
39
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
38
40
  "react": "^18.2.0"
39
41
  },
40
42
  "dependencies": {
@@ -42,11 +44,13 @@
42
44
  },
43
45
  "devDependencies": {
44
46
  "@deepseek-ai/cordis": "^4.0.1",
47
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
45
48
  "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
46
49
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
47
50
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
48
51
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
49
52
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
53
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
50
54
  "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.8",
51
55
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
52
56
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
@@ -354,6 +354,8 @@
354
354
  }
355
355
 
356
356
  .selectOption {
357
+ /* 弹层是 max-height 受限的 flex 列:选项必须保持自然行高,超出交给弹层滚动 */
358
+ flex-shrink: 0;
357
359
  border-radius: 6px;
358
360
  padding: 6px 10px;
359
361
  font-size: 13px;
@@ -1,5 +1,6 @@
1
+ import { spawnSync } from 'node:child_process'
1
2
  import { createHash } from 'node:crypto'
2
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
4
  import { dirname, join } from 'node:path'
4
5
  import { dshHome } from './dsh-home.ts'
5
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
@@ -68,6 +69,53 @@ function processIsAlive(pid: number): boolean {
68
69
  }
69
70
  }
70
71
 
72
+ const PROCESS_PROBE_TIMEOUT_MS = 3000
73
+
74
+ let ownStartTime: number | undefined
75
+ let ownStartTimeResolved = false
76
+
77
+ /**
78
+ * Best-effort start time (Unix epoch ms) of a live process. Used to prove
79
+ * whether the ledger lock really belongs to the PID recorded in it, so a
80
+ * crash leftover whose PID was reused by an unrelated process (issue #786)
81
+ * is detected as stale instead of blocking startup forever. Returns
82
+ * undefined when the platform probe is unavailable; callers fail closed.
83
+ */
84
+ function processStartTimeMs(pid: number): number | undefined {
85
+ if (process.platform === 'win32') {
86
+ const probe = spawnSync(
87
+ 'powershell',
88
+ ['-NoProfile', '-NonInteractive', '-Command',
89
+ '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
90
+ { timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
91
+ )
92
+ if (probe.status !== 0 || probe.stdout.length === 0) return undefined
93
+ const started = Number(probe.stdout.toString('utf8').trim())
94
+ return Number.isFinite(started) ? started : undefined
95
+ }
96
+ // POSIX: ps lstart with a forced English locale, falling back to the
97
+ // elapsed-seconds column when lstart cannot be parsed.
98
+ const env = { ...process.env, LC_ALL: 'C' }
99
+ const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
100
+ if (probe.status === 0 && probe.stdout.length > 0) {
101
+ const started = Date.parse(probe.stdout.toString('utf8').trim())
102
+ if (Number.isFinite(started)) return started
103
+ }
104
+ const elapsed = spawnSync('ps', ['-o', 'etimes=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
105
+ if (elapsed.status !== 0 || elapsed.stdout.length === 0) return undefined
106
+ const seconds = Number(elapsed.stdout.toString('utf8').trim())
107
+ if (!Number.isFinite(seconds)) return undefined
108
+ return Date.now() - seconds * 1000
109
+ }
110
+
111
+ function ownProcessStartTimeMs(): number | undefined {
112
+ if (!ownStartTimeResolved) {
113
+ ownStartTimeResolved = true
114
+ ownStartTime = processStartTimeMs(process.pid)
115
+ }
116
+ return ownStartTime
117
+ }
118
+
71
119
  function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
72
120
  if (a.endedAt === undefined && b.endedAt !== undefined) return b
73
121
  if (b.endedAt === undefined && a.endedAt !== undefined) return a
@@ -512,7 +560,7 @@ export class HostTaskLedger {
512
560
  for (let attempt = 0; attempt < 2; attempt += 1) {
513
561
  try {
514
562
  const fd = openSync(this.lockFile, 'wx', 0o600)
515
- writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken }), { encoding: 'utf8' })
563
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt: ownProcessStartTimeMs() }), { encoding: 'utf8' })
516
564
  fsyncSync(fd)
517
565
  try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
518
566
  return fd
@@ -520,14 +568,35 @@ export class HostTaskLedger {
520
568
  const code = (error as NodeJS.ErrnoException).code
521
569
  if (code !== 'EEXIST') throw error
522
570
  let pid: number | undefined
571
+ let ownerStartedAt: number | undefined
523
572
  try {
524
- const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown }
573
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
525
574
  if (typeof owner.pid === 'number') pid = owner.pid
575
+ if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
526
576
  } catch {
527
577
  throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
528
578
  }
529
579
  if (pid !== undefined && processIsAlive(pid)) {
530
- throw new Error(`task-board ledger is already owned by process ${pid}`)
580
+ const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
581
+ // A reused PID is exposed when the live process identity no longer
582
+ // matches the recorded one: either the recorded start time differs,
583
+ // or (legacy locks without one) the lock file predates the live
584
+ // process and therefore cannot have been written by it. Takeover is
585
+ // safe in both cases — the original owner is gone.
586
+ const staleReuse = actualStartedAt !== undefined && (
587
+ ownerStartedAt !== undefined
588
+ ? actualStartedAt !== ownerStartedAt
589
+ : (() => {
590
+ try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
591
+ })()
592
+ )
593
+ if (!staleReuse) {
594
+ const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt === ownerStartedAt
595
+ const hint = confirmedOwner
596
+ ? ''
597
+ : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
598
+ throw new Error(`task-board ledger is already owned by process ${pid}${hint}`)
599
+ }
531
600
  }
532
601
  try { unlinkSync(this.lockFile) } catch (unlinkError) {
533
602
  if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
@@ -1,4 +1,5 @@
1
1
  import type { ApiProxy, RpcId } from '@deepseek-ai/dsh-host-apiproxy'
2
+ import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
2
3
  import type { TaskRecord } from './core/tasks.ts'
3
4
 
4
5
  function request<T>(payload: T) {
@@ -15,6 +16,19 @@ export type SessionSummary = Extract<
15
16
  { ok: true }
16
17
  >['value']['items'][number]
17
18
 
19
+ type ExecutionSessionId = Extract<
20
+ Awaited<ReturnType<ApiProxy['sessions']['create']>>['result'],
21
+ { ok: true }
22
+ >['value']['sessionId']
23
+
24
+ export interface SessionCommandDispatcher {
25
+ execute(
26
+ sessionId: ExecutionSessionId,
27
+ line: string,
28
+ signal: AbortSignal,
29
+ ): Promise<CommandResult | undefined>
30
+ }
31
+
18
32
  export type ExecutionInspection =
19
33
  | { outcome: 'pending' }
20
34
  | { outcome: 'succeeded' }
@@ -36,7 +50,10 @@ function isErrorTurnEnd(data: unknown): boolean {
36
50
  }
37
51
 
38
52
  export class HostExecutionRunner {
39
- constructor(private readonly api: ApiProxy) {}
53
+ constructor(
54
+ private readonly api: ApiProxy,
55
+ private readonly commands?: SessionCommandDispatcher,
56
+ ) {}
40
57
 
41
58
  async launch(task: TaskRecord): Promise<string> {
42
59
  if (task.workspaceId !== undefined) {
@@ -63,13 +80,10 @@ export class HostExecutionRunner {
63
80
  const renamed = await this.api.sessions.rename(request({ sessionId, title: task.title }))
64
81
  if (!renamed.result.ok) throw failure(renamed.result.error)
65
82
  if (task.permission !== undefined) {
66
- const command = await this.api.sessions.prompt(request({
67
- sessionId,
68
- mode: 'queue' as const,
69
- content: [{ type: 'text' as const, text: `/permission ${task.permission}` }],
70
- }))
71
- if (!command.result.ok) throw failure(command.result.error)
72
- if (command.result.value.command?.kind !== 'success') throw new Error('permission command was not acknowledged')
83
+ if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
84
+ const command = await this.commands.execute(sessionId, `/permission ${task.permission}`, AbortSignal.timeout(30_000))
85
+ if (command === undefined) throw new Error('permission command was not acknowledged')
86
+ if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
73
87
  }
74
88
  const prompt = await this.api.sessions.prompt(request({
75
89
  sessionId,
@@ -2,7 +2,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
2
2
  import { nextRunAtMs } from './core/schedule.ts'
3
3
  import type { TaskRecord } from './core/tasks.ts'
4
4
  import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
5
- import { HostExecutionRunner, SessionLaunchError, type SessionSummary } from './host-runner.ts'
5
+ import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary } from './host-runner.ts'
6
6
  import { PowerInhibitor } from './power-inhibitor.ts'
7
7
  import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
8
8
 
@@ -25,9 +25,14 @@ export class TaskBoardHostService {
25
25
  private lastPowerJson = ''
26
26
  private readonly now: () => number
27
27
 
28
- constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
28
+ constructor(api: ApiProxy, options: {
29
+ ledger?: HostTaskLedger
30
+ power?: PowerInhibitor
31
+ now?: () => number
32
+ commandDispatcher?: SessionCommandDispatcher
33
+ } = {}) {
29
34
  this.ledger = options.ledger ?? new HostTaskLedger()
30
- this.runner = new HostExecutionRunner(api)
35
+ this.runner = new HostExecutionRunner(api, options.commandDispatcher)
31
36
  this.power = options.power ?? new PowerInhibitor()
32
37
  this.now = options.now ?? Date.now
33
38
  this.ledger.subscribe(() => {
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
+ import type {} from '@deepseek-ai/dsh-agent'
11
+ import type {} from '@deepseek-ai/dsh-commands'
10
12
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
11
13
  import z from 'schemastery'
12
14
  import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -22,10 +24,10 @@ const SECTION_ORDER = 200
22
24
  /** Default environment variable holding the authenticated proxy token. */
23
25
  export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
24
26
 
25
- export const inject = ['systemPrompt', 'apiProxy', 'webServer']
27
+ export const inject = ['systemPrompt', 'apiProxy', 'webServer', 'agents', 'commands']
26
28
 
27
29
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
28
- export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
30
+ export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
29
31
 
30
32
  /**
31
33
  * Settings namespace of the board's announcement capability — the section the
@@ -87,7 +89,15 @@ const DEFAULT_ANNOUNCE = true
87
89
  export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
88
90
 
89
91
  function applyImpl(ctx: Context, config?: Config): void {
90
- const host = new TaskBoardHostService(ctx.apiProxy)
92
+ const host = new TaskBoardHostService(ctx.apiProxy, {
93
+ commandDispatcher: {
94
+ async execute(sessionId, line, signal) {
95
+ const agent = ctx.agents.get(sessionId)
96
+ if (agent === undefined) throw new Error(`execution session ${sessionId} is not available`)
97
+ return (await ctx.commands.execute(agent, line, [], signal))?.result
98
+ },
99
+ },
100
+ })
91
101
  host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false)
92
102
  host.start()
93
103
  ctx.effect(() => {