@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 +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +99 -19
- package/lib/types/host-runner.d.ts +10 -1
- package/lib/types/host-service.d.ts +2 -1
- package/lib/types/index.d.ts +1 -1
- package/package.json +5 -1
- package/src/client/settings-card.module.css +2 -0
- package/src/host-ledger.ts +73 -4
- package/src/host-runner.ts +22 -8
- package/src/host-service.ts +8 -3
- package/src/index.ts +13 -3
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:
|
|
6
|
-
README.zh.md:
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
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;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
+
"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",
|
package/src/host-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/host-runner.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
package/src/host-service.ts
CHANGED
|
@@ -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: {
|
|
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(() => {
|